diff --git a/src/MeshWeaver.Documentation/Data/Architecture.md b/src/MeshWeaver.Documentation/Data/Architecture.md
index b97d8e9563..e83f6b46a2 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture.md
@@ -196,6 +196,7 @@ Each theme starts with its introductory page, followed by related architecture t
- [Logon Actions](LogonActions) — per-user work at logon, run as the user
- [Unanchored Security Reads](UnanchoredSecurityReads) — why the permission fold reads mesh-wide, and why pinning it to the viewer's partition is a silent revocation-fails-open bug
- [A Denial Is an Answer](DenialIsAnAnswer) — a check on a hub with no evaluator grants Permission.All, and a refusal the mesh decided is rendered, never raised
+- [PublicRead and Denies](PublicReadAndDenies) — a Public/Anonymous deny under a `PublicRead` policy is honoured by the SQL read path and ignored by the C# evaluator; what that split exposes, and what each remedy costs
- [Who Owns a Partition's Access Shape](PartitionAccessOwnership)
- [Partition Ownership Resolution](PartitionOwnershipResolution) — the four create-path checks that ask whether a NodeType owns its partition, what one resolution costs for a type declared in mesh content, which of them share ONE view and which deliberately keeps its own, and how a nested instance of such a type is refused from the definition's durable row without activating the type's hub
- [OWASP ZAP Scan — 3.0.0 (6 September 2026)](SecurityScan_3_0_0)
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md b/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md
index b9138bab10..90505afd22 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md
@@ -1250,7 +1250,9 @@ The predicate above used to carry a third term — `public_read_node_type OR …
- **Connecting it would have been a breach.** ~24 node types declared public read, including `Thread`/`ThreadMessage` (private conversations), `Markdown`/`Code`/`Document` (most content), and `Course`/`Module`/`Exercise`/`ExerciseAttempt` (paid course content and learners' own submissions).
- **The shape was unsafe regardless of the type list.** Being an unconditional `OR` in front of the node fold, it short-circuited the longest-prefix resolution — i.e. it overrode DENY rows, which is precisely where store/course paywall gating lives. And `PermissionEvaluator` has no node-type-keyed term, so the SQL and evaluator paths would have diverged.
-**Declare public read with a mechanism both read paths honour instead:** a `PartitionAccessPolicy` `_Policy` node with `PublicRead = true` (issue #603 — projected as allow-`Read` rows for `Public`/`Anonymous` that *participate in* the prefix fold, so a deeper deny still wins), or a [`NodeTypeGate`](#type-declared-subtree-gates-nodetypegate) (issue #701) for a type that opens a short, explicitly listed set of surfaces on its own subtree.
+**Declare public read with a mechanism both read paths honour instead:** a `PartitionAccessPolicy` `_Policy` node with `PublicRead = true` (issue #603 — projected as allow-`Read` rows for `Public`/`Anonymous` that *participate in* the prefix fold, so a deeper read **cap** still wins), or a [`NodeTypeGate`](#type-declared-subtree-gates-nodetypegate) (issue #701) for a type that opens a short, explicitly listed set of surfaces on its own subtree.
+
+🚨 **A deeper ROLE DENY is a different question, and the two read paths answer it differently.** The parenthetical above says *cap* on purpose: read as "a deeper deny of any kind still wins" it is wrong for the C# evaluator, and that reading is exactly how a submission inbox came to be published under a public partition. The SQL projection resolves one row set by longest prefix, so a `Public`/`Anonymous` deny at a deeper prefix wins there ("the store-gating shape and it is intentional"); `PermissionEvaluator` instead ORs the public grant in separately from the role fold, so the same deny is inert. Measured 2026-09-20 and pinned by `PublicReadIsNotSuppressedByADenyTest`. Full account, and what each remedy costs: [PublicRead and Denies](../PublicReadAndDenies) (MeshWeaver#4716).
### Public policy grants and deeper read caps
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md
new file mode 100644
index 0000000000..9033f95492
--- /dev/null
+++ b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md
@@ -0,0 +1,203 @@
+---
+Name: PublicRead and Denies
+Category: Architecture
+Description: A Public/Anonymous deny beats PartitionAccessPolicy.PublicRead on the SQL read path and not in the C# evaluator — a measured two-executor divergence, which is the paywall-bypass shape. What it exposes, why three components believed the deny protected them, and what each candidate remedy costs.
+Icon:
+---
+
+# PublicRead and Denies
+
+**A `Public`/`Anonymous` Viewer DENY under a `PartitionAccessPolicy` that grants `PublicRead` is
+IGNORED by the C# `PermissionEvaluator` — measured. The PostgreSQL projection's own comment says its
+path honours the same deny; if that holds, the two read paths disagree, which is the paywall-bypass
+shape. Either way the submission inbox that prompted this is not closed by the deny.**
+
+Three components assert that the deny protects the segment, and one of them ships a protection built
+on it. On the C# path it protects nothing. 🚨 **The SQL half of that sentence is a CLAIM, not a
+reading** — it comes from the projection's source comment and no Postgres portal was exercised for
+this page. Treat "hidden from every listing, readable by exact path" as the shape to go and confirm,
+never as an established contract: a remedy built on an unverified half is how this defect was created
+in the first place.
+
+Confidence, stated separately because it differs:
+
+- **MEASURED** (this repository, a real monolith mesh, 2026-09-20): the C# fold lets both well-known
+ subjects read a child carrying their own Viewer deny. Pinned by
+ `PublicReadIsNotSuppressedByADenyTest`.
+- **MEASURED** (`memex.meshweaver.cloud`, read-only, 2026-09-20): the `Feedback` partition is
+ world-readable including its submission inbox, and carries no deny at all.
+- **READ, NOT EXECUTED**: the PostgreSQL projection's own comment states the opposite behaviour. No
+ Postgres portal was exercised for this page, so the divergence is inferred from that comment plus
+ the measured C# half. **Verifying it against a live Postgres portal is the first step of any fix.**
+
+## The one line of the fold that decides it
+
+`PermissionEvaluator.FoldScopes` walks the scope hierarchy of the path being evaluated. Two
+independent accumulators come out of that walk:
+
+- `roleIds` — the roles the subject holds, unioned per scope, and a **deny subtracts from this one**
+ (`roleIds = roleIds.Except(deniedRoles)`).
+- `publicGrant` — the policy-driven public grant, ORed in per scope
+ (`if (policy.PublicRead) publicGrant |= Permission.Read`).
+
+The result is the role-derived permission (capped) **OR** `publicGrant`. So:
+
+> A deny removes a ROLE. `PublicRead` is not a role. There is nothing for the deny to take away.
+
+The only thing that touches `publicGrant` is a permission CAP from a policy deeper on the chain
+(`publicGrant &= scopeCap`) — i.e. a `PartitionAccessPolicy { Read = false }`.
+
+### …and the one line of SQL that decides it differently
+
+`PostgreSqlSchemaInitializer` (MeshWeaver.Plugins) projects everything into one row set,
+`user_effective_permissions(user_id, node_path_prefix, permission, is_allow)`, resolved **per subject
+by longest prefix**. A `PublicRead` policy becomes allow-`Read` rows at the policy's namespace for
+`Public` and `Anonymous`; a Viewer deny becomes deny rows at the assignment's scope. Its own comment
+spells out the consequence:
+
+> Runs AFTER the policy-cap deny fold with `DO UPDATE is_allow = true` so at the **SAME** prefix the
+> public grant wins — matching the live override order. (A deny at a **LONGER** prefix still wins the
+> per-subject longest-prefix query fold; that is the store-gating shape and it is intentional.)
+
+So the intended rule is *"a deeper deny beats an inherited public grant"*, the comment says the SQL
+path implements it, and the C# path measurably does not. 🚨 That comment is the author's statement of
+intent, not a test result, and this page did not execute it — **the same "a stated convention is not a
+measured population" trap this whole issue is an instance of.** Verify it against a live Postgres
+portal before treating the divergence as established, and before building any remedy on it. What is
+not in doubt either way: the paywall gating rests on this shape, so whichever path is wrong, being
+wrong about it is expensive.
+
+## Why the belief looked true
+
+Because it **is** true of the other public shape, and the two are easy to conflate. The platform
+opens a partition to everyone in two different ways:
+
+| Shape | How the read is granted | Does a child deny close it, in the C# fold? |
+|---|---|---|
+| **Fully-public** — `{partition}/_Policy` with `PublicRead = true` (`PackageInstaller.EnsurePartitionPublicRead`, `PluginGate.OpenPolicy`) | a POLICY grant | **No.** The deny is inert. |
+| **Scoped** — `Public`+`Anonymous` Viewer GRANTS at the partition root (`PackageInstaller.EnsureScopedPublicRead`, the Store's `CatalogGate`) | a ROLE grant, inherited downward | **Yes.** The deny removes the role. |
+
+The rule was written from the second row and applied to the first, where the C# fold does not honour
+it. Both rows are pinned by `PublicReadIsNotSuppressedByADenyTest`, deliberately in one file, so the
+difference is visible rather than something the next reader has to infer.
+
+## The workaround available today is a blackout, not a gate
+
+A deeper `PartitionAccessPolicy { Read = false }` does suppress the inherited public grant in the C#
+fold. But
+`GetPermissionCap()` produces one mask that is ANDed into **every** subject's permissions, admins
+included — the record says so in its own summary ("Caps effective permissions at a namespace scope
+for ALL users (including Admins)"). Core's own `PublicReadPolicyScopeTest` already pins the cost:
+`RolePolicy/Capped/Page` denies Read to a viewer holding a real Viewer grant one scope up.
+
+So `Read = false` cannot express *"closed to the public, still readable by the people who triage
+it"*. The only identity it does not bind is `System`, which short-circuits the fold entirely.
+
+**So in the C# fold there is no way to withhold an inherited public grant without capping roles** —
+while the SQL fold does exactly that, for the two well-known subjects only, which is precisely the
+behaviour wanted. The divergence is the defect; everything below is a consequence of it.
+
+## The case that found it
+
+`Systemorph/MeshWeaver#4716`. The `Feedback` package's manifest declares both halves of the
+intent explicitly:
+
+```json
+"preInstalled": true,
+"protectedSegments": ["_Submissions"]
+```
+
+`Feedback/_Submissions/{id}` is where the `/feedback` flow files every submission, written as
+System so a low-privilege user can submit. What was measured, read-only, on 2026-09-20:
+
+| Read | Result |
+|---|---|
+| `Feedback/_Policy` | `PartitionAccessPolicy { PublicRead: true }`, `lastModifiedBy: system-security` |
+| `Feedback/_Submissions/demo-preview` | exists, `nodeType: Feedback/Feedback` (a demo item — no real user data today) |
+| `Feedback/_Submissions/_Access/Public_Access` | **Not found** — no deny exists |
+| `Feedback/_Access/Public_Access` | `Public — Viewer` — reads back fine, so the absence above is the node's and not a filtered read |
+
+Note `search` did not return `Feedback/_Submissions/*` while `get` returned the node — the known
+"search can miss what get returns" asymmetry. The denominator came from `get`.
+
+Three separate components assert the rule that only one of the two read paths implements, and the
+third one acts on it:
+
+1. `PackageInstaller.EnsurePartitionPublicRead`'s remarks stated it as a general rule, with no
+ mention that one read path ignores it. **Corrected** by the change that added this page.
+2. #4716's triage comment cited those remarks to conclude that a per-path deny *"is not
+ hypothetical"*.
+3. The Store's `PluginGate` pre-installed arm (in-mesh source in `MeshWeaver.Plugins`, so invisible
+ to any `dotnet build` or `grep --include='*.cs'` over this repository) writes exactly that deny
+ pair for every declared `ProtectedSegments` entry, under a comment naming this very inbox. On the
+ C# path **it is a protection that cannot protect**; on the SQL path it produces the split-visibility
+ shape. It is also not currently present on the live partition, so the exposure persists until it
+ lands — a second and independent reason.
+
+Core additionally never reads `protectedSegments` at all — `NodeRepoPackageSource.Peek` does not
+carry it onto `PackageManifest` — so the installer could not honour the declaration even if a
+mechanism existed. That is the same dead-metadata class `preInstalled`, `publicSegments`,
+`contactEmail` and `tier` each were, except this one fails OPEN.
+
+## What each candidate remedy costs
+
+Nothing here is free, and none of it is a core-only change. This is the part that needs a decision,
+not a patch.
+
+**1. Reconcile the C# fold with the SQL one — make a deeper well-known deny withhold the inherited
+public grant.** This is the fix that matches the intent, needs no new concept, no manifest change and
+no data migration, and is **core-only**, because the SQL side already behaves this way. The segment
+becomes gated and a reviewer's own grant still reads it, since a deny touches only `Public` and
+`Anonymous`.
+
+What it takes, concretely: `publicGrant` must stop being an OR-accumulator and become a
+longest-prefix (last-writer-wins) chain over the well-known subjects' rows — allow where a
+`PublicRead` policy or a Public/Anonymous grant sits, deny where a Public/Anonymous deny sits, caps
+still ANDed in. 🚨 The state for that is not currently reaching the fold: `ComputeScopeRoles` filters
+assignments to the EVALUATED subject, so a Public/Anonymous deny is absent from what
+`ComputeRoleState` receives whenever the viewer is somebody else. So the change is not one line —
+it widens the snapshot the long-lived fold consumes.
+
+🚨 **Do this one with the evidence in front of you, and verify the Postgres half first.** This fold
+has an incident history on exactly these lines: #974 (an empty seed fails OPEN), the 2026-08-05
+paywall bypass (claim roles folded in here), and a 2026-09-11 correction whose four failures were all
+"reads through a deeper read cap". It also changes permission outcomes for every subject on every
+path, so it wants its own test matrix and a `MeshWeaver.Security.Test` run on the Plugins side.
+
+**2. Retract `publicRead` on the partition and open it with root grants instead.** Uses only shipped
+machinery, makes the denies work on both paths, and needs no evaluator change. Two costs. First, `PublicSurfaceCarriesApi` keys on the POLICY's
+public grant, not on an `AccessAssignment` — it is what "keeps MCP tokens working on `Doc/`, `Agent/`
+and every installed package partition, which `PackageInstaller` makes readable through exactly this
+policy" — so an API client holding no role loses the partition's public half. Second, **it
+ping-pongs**: `PluginGate.OpenPolicy` rewrites `{ publicRead: true }` on every reconcile for a
+pre-installed partition, and core's boot pass re-asserts it too. `EnsurePartitionPublicRead`'s
+remarks record what that fight already cost once — a CD seal, over 26 denies retired and re-written
+between two components that each believed they owned the shape. Whoever takes this option changes
+**one** writer, not two.
+
+**3. Move the inbox out of the public partition** (#4716's own first option). Submissions go to a
+partition with no public grant; the public catalog half stays public. Cleanest in the access model
+and needs no new mechanism — but it is a change to where the Feedback plugin WRITES, plus a
+migration of what is already filed, and it lives in `MeshWeaver.Plugins`.
+
+**4. Decide the submissions were always meant to be public, and record that decision on the node.**
+Still a legitimate answer, and the only one no amount of code reading can choose.
+
+## What this page does not settle
+
+Which of the four, and two things it deliberately did not establish:
+
+- **The Postgres half was not executed.** Its behaviour here is its own comment, not a reading. Every
+ remedy above assumes the SQL path denies a deeper-prefix deny under a public grant; confirm that on
+ a live Postgres portal before building on it.
+- **Whether any partition other than `Feedback` is exposed this way.** The sweep that would answer it
+ is every `PartitionAccessPolicy` with `publicRead: true` whose package manifest declares
+ `protectedSegments` — not run, because it reads across partitions on a live public instance.
+
+## See also
+
+- [Access Control](../AccessControl) — the permission model and the Admin partition
+- [Query Provider Parity](../QueryProviderParity) — why a semantic honoured by one fold and not the
+ other is a security defect, not an inconsistency
+- `PublicReadIsNotSuppressedByADenyTest` (`test/MeshWeaver.Graph.Test/`) — the three shapes, executable
+- `PublicReadPolicyScopeTest` (`test/MeshWeaver.Graph.Test/`) — scope precedence, incl. the cap's cost
diff --git a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs
index d9d8f425e1..299e17f8da 100644
--- a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs
+++ b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs
@@ -1067,11 +1067,37 @@ public static IObservable EnsureDeclaredAccess(
/// is "no current code produces this", the survey has to cover the MESH, not the compiler's
/// view of one repository.
///
- /// Order matters: denies first, policy last. The denies are what actually hide the
- /// content (an explicit deny beats PublicRead), and the policy node is the marker that
- /// says "this partition is already in the declared shape". Writing the marker first would strand
- /// a half-swept partition permanently, so the sweep runs BEFORE the policy write and a failure
+ /// Order matters: denies first, policy last. While the policy still withholds public
+ /// read the denies are what hides the content, and the policy node is the marker that says "this
+ /// partition is already in the declared shape". Writing the marker first would strand a
+ /// half-swept partition permanently, so the sweep runs BEFORE the policy write and a failure
/// simply leaves the old policy in place for the next boot to retry.
+ ///
+ /// 🚨 This paragraph used to add "an explicit deny beats PublicRead" as a general
+ /// rule, and on the C# read path that is FALSE — measured (MeshWeaver#4716).
+ /// PermissionEvaluator.ComputeRoleState subtracts denied roles from roleIds and ORs
+ /// the public grant in SEPARATELY and afterwards — a deny removes a ROLE, PublicRead is not
+ /// a role, so once the policy below is written every Public/Anonymous deny under it is INERT
+ /// there. Measured on a monolith mesh and pinned by
+ /// PublicReadIsNotSuppressedByADenyTest; the only thing that does withhold it on that path
+ /// is a deeper Read = false cap, which is ANDed into every role-derived permission too — a
+ /// blackout, not a gate.
+ ///
+ /// 🚨 The rule is still the stated INTENT, and the Postgres projection CLAIMS to
+ /// implement it — it emits this policy as allow-Read rows at this prefix and records
+ /// that "a deny at a LONGER prefix still wins the per-subject longest-prefix query fold; that is
+ /// the store-gating shape and it is intentional". 🚨 That is its author's comment, NOT a test
+ /// result: no Postgres path was executed for #4716, so "the two read paths disagree" is the thing
+ /// to go and confirm, not an established contract — and confirming it is the first step of any fix,
+ /// because a remedy built on an unverified half is how this defect arose. If it holds, the split is
+ /// the paywall-bypass shape this evaluator carries its own account of (79,650 characters of paid
+ /// course content served by exact path while search correctly denied it).
+ ///
+ /// Either way the sentence was load-bearing HERE: #4716's triage cited it to conclude a
+ /// per-path deny would protect a submission inbox, and the Store's PluginGate pre-installed
+ /// arm implements exactly that for a manifest's ProtectedSegments — so on this path the
+ /// protection it applies is none. Full measurement, the confidence on each half, and what each
+ /// candidate remedy costs: Doc/Architecture/PublicReadAndDenies.
///
private static IObservable EnsurePartitionPublicRead(
IMessageHub hub, PackageManifest manifest, string partition, ILogger? logger)
@@ -1280,7 +1306,19 @@ private static bool IsWellKnownDeny(MeshNode node, IMessageHub hub) =>
/// The child walk follows the PluginGate conventions — underscore satellites are
/// never gated here (their protection is the plugin machinery's ProtectedSegments
/// concern) and the well-known Public segment is always public — so the installer's
- /// shape and the Store's reconcile converge on the same nodes instead of fighting. Segments
+ /// shape and the Store's reconcile converge on the same nodes instead of fighting.
+ ///
+ /// 🚨 That delegation is sound for THIS shape and a real gap for the other one
+ /// (MeshWeaver#4716). The ProtectedSegments machinery it hands off to writes
+ /// Public/Anonymous DENIES, which work here — the read is a root role GRANT, and a deny removes a
+ /// role — and which the C# read path does NOT honour under the fully-public shape's
+ /// PublicRead policy, though the SQL path does; see the remarks on
+ /// . A pre-installed partition takes that other shape, so a
+ /// satellite it declares protected is published to that path anyway and no component reports it.
+ /// Do not "fix" it by gating satellites from the _ prefix: a package is entitled to publish
+ /// one, and the declaration is what distinguishes them.
+ ///
+ /// Segments
/// come from the paths this install wrote UNIONED with the partition's current children (read
/// as System); every node is create-only and the writes run SEQUENTIALLY (the access table
/// deadlocks under parallel writers, 40P01).
diff --git a/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs
new file mode 100644
index 0000000000..e28634e847
--- /dev/null
+++ b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs
@@ -0,0 +1,187 @@
+using System.Reactive.Linq;
+using MeshWeaver.Fixture;
+using MeshWeaver.Hosting.Monolith.TestBase;
+using MeshWeaver.Mesh;
+using MeshWeaver.Mesh.Security;
+using MeshWeaver.Mesh.Services;
+using MeshWeaver.Messaging;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace MeshWeaver.Graph.Test;
+
+///
+/// 🚨 In THIS fold, a Public/Anonymous DENY does not suppress an inherited
+/// PartitionAccessPolicy.PublicRead — and the SQL fold's own comment says it does. The C#
+/// half is measured here; the divergence that follows from it is a CLAIM about a path this file does
+/// not execute (Systemorph/MeshWeaver#4716).
+///
+/// What this file pins, and what it does not. Every assertion below is a MEASUREMENT of
+/// the C# PermissionEvaluator against a real monolith mesh. The Postgres half is NOT executed
+/// here; what is known about it is its projection's own comment in
+/// MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs (MeshWeaver.Plugins), which
+/// projects a PublicRead policy as allow-Read rows at the policy's prefix and then
+/// states: "A deny at a LONGER prefix still wins the per-subject longest-prefix query fold; that
+/// is the store-gating shape and it is intentional." So the intended behaviour is the OPPOSITE of
+/// what this file measures, and the divergence is the defect — not the behaviour recorded here.
+/// 🚨 So do not "fix" a red in this file by relaxing an assertion. When the folds are
+/// reconciled, the first theory below flips to BeFalse and this summary is what says why.
+///
+/// Why it matters: the two shapes of a read path disagreeing IS the paywall bypass. The
+/// evaluator carries its own account of the last time (2026-08-05): a get by exact path served
+/// 79,650 characters of paid course content to an unentitled caller while search, over the SQL
+/// fold, correctly denied the same node. A Public/Anonymous deny written under a PublicRead
+/// partition reproduces that shape exactly — hidden from every listing, readable by exact path.
+///
+/// Three components assert the behaviour this file falsifies for the C# fold.
+/// PackageInstaller.EnsurePartitionPublicRead's remarks stated it as fact; #4716's triage cited
+/// those remarks to conclude a per-path deny on a submission inbox "is not hypothetical"; and
+/// the Store's PluginGate pre-installed arm (in-mesh source in MeshWeaver.Plugins, invisible to
+/// any build or grep over this repository) IMPLEMENTS it — for every segment a manifest declares in
+/// ProtectedSegments it writes exactly this deny pair, under a comment saying that without it
+/// "an open partition would publish a satellite holding user-submitted data (Feedback's
+/// _Submissions inbox)".
+///
+/// Where the divergence lives, in one line.ComputeRoleState subtracts denied roles
+/// from roleIds, and ORs the public grant in SEPARATELY and afterwards
+/// (publicGrant |= Permission.Read), accumulating it down the chain. A deny removes a ROLE, and
+/// PublicRead is not a role, so there is nothing for it to take away. SQL instead resolves one
+/// row set by LONGEST PREFIX, where a deeper deny row simply wins. Closing the gap means folding the
+/// public chain last-writer-wins over the well-known subjects' rows, which this fold cannot do today:
+/// ComputeScopeRoles filters assignments to the EVALUATED subject, so the Public/Anonymous deny
+/// at a deeper scope is not even in the state ComputeRoleState receives.
+///
+/// Full measurement, and what each candidate remedy costs:
+/// Doc/Architecture/PublicReadAndDenies.
+///
+public class PublicReadIsNotSuppressedByADenyTest(ITestOutputHelper output) : MonolithMeshTestBase(output)
+{
+ /// A real signed-in subject holding Viewer at a root — the "reviewer" of the third theory.
+ private const string Reviewer = "deny-vs-publicread-reviewer";
+
+ // ConfigureMeshBase, never ConfigureMesh: the default test configuration grants Public Admin,
+ // which would make every reading below Read regardless of the model.
+ ///
+ protected override MeshBuilder ConfigureMesh(MeshBuilder builder)
+ => ConfigureMeshBase(builder).AddMeshNodes(
+ // ── Shape 1: public read from the POLICY, plus a child deny ──────────────────
+ new MeshNode("PolicyOpen") { NodeType = "Markdown" },
+ AssignmentNodeFactory.Policy("PolicyOpen", new PartitionAccessPolicy { PublicRead = true }),
+ new MeshNode("Cover", "PolicyOpen") { NodeType = "Markdown" },
+ new MeshNode("Gated", "PolicyOpen") { NodeType = "Markdown" },
+ AssignmentNodeFactory.UserRole(WellKnownUsers.Public, "Viewer", "PolicyOpen/Gated", denied: true),
+ AssignmentNodeFactory.UserRole(WellKnownUsers.Anonymous, "Viewer", "PolicyOpen/Gated", denied: true),
+ new MeshNode("Page", "PolicyOpen/Gated") { NodeType = "Markdown" },
+
+ // ── Shape 2: public read from root GRANTS, plus the same child deny ──────────
+ new MeshNode("GrantOpen") { NodeType = "Markdown" },
+ AssignmentNodeFactory.UserRole(WellKnownUsers.Public, "Viewer", "GrantOpen"),
+ AssignmentNodeFactory.UserRole(WellKnownUsers.Anonymous, "Viewer", "GrantOpen"),
+ new MeshNode("Cover", "GrantOpen") { NodeType = "Markdown" },
+ new MeshNode("Gated", "GrantOpen") { NodeType = "Markdown" },
+ AssignmentNodeFactory.UserRole(WellKnownUsers.Public, "Viewer", "GrantOpen/Gated", denied: true),
+ AssignmentNodeFactory.UserRole(WellKnownUsers.Anonymous, "Viewer", "GrantOpen/Gated", denied: true),
+ new MeshNode("Page", "GrantOpen/Gated") { NodeType = "Markdown" },
+
+ // ── Shape 3: the only thing that suppresses the policy grant — and what it costs ──
+ new MeshNode("CapOpen") { NodeType = "Markdown" },
+ AssignmentNodeFactory.Policy("CapOpen", new PartitionAccessPolicy { PublicRead = true }),
+ AssignmentNodeFactory.UserRole(Reviewer, "Viewer", "CapOpen"),
+ new MeshNode("Gated", "CapOpen") { NodeType = "Markdown" },
+ AssignmentNodeFactory.Policy("CapOpen/Gated", new PartitionAccessPolicy { Read = false }),
+ new MeshNode("Page", "CapOpen/Gated") { NodeType = "Markdown" });
+
+ ///
+ /// The effective permission a SUBJECT holds on a path. Asserts the FIRST decision, so an initial
+ /// leak cannot hide behind a later matching answer.
+ ///
+ private Task Effective(string path, string subject, CancellationToken cancellationToken)
+ {
+ var access = Mesh.ServiceProvider.GetRequiredService();
+ var context = new AccessContext { ObjectId = subject, Name = subject };
+ access.SetContext(context);
+ access.SetHostIdentity(context);
+ return Mesh.GetEffectivePermissions(path, subject)
+ .FirstAsync().Timeout(TestTimeouts.Quick).Await(cancellationToken);
+ }
+
+ private async Task CanRead(string path, string subject, CancellationToken cancellationToken)
+ {
+ var permission = await Effective(path, subject, cancellationToken);
+ Output.WriteLine($"{subject} on {path}: {permission}");
+ return permission.HasFlag(Permission.Read);
+ }
+
+ ///
+ /// 🚨 THE FALSIFICATION. Both well-known subjects still READ a child carrying their own
+ /// Viewer DENY, because the partition's policy grants public read and the fold ORs that in after
+ /// the deny subtraction. Every deny written to protect a segment under such a policy — including
+ /// every one the Store's gate writes from a manifest's ProtectedSegments — is inert.
+ ///
+ [Theory(Timeout = 60_000)]
+ [InlineData(WellKnownUsers.Anonymous)]
+ [InlineData(WellKnownUsers.Public)]
+ public async Task ADenyUnderAPublicReadPolicy_DoesNotWithholdRead(string subject)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ (await CanRead("PolicyOpen/Gated/Page", subject, cancellationToken)).Should().BeTrue(
+ "a deny removes a ROLE, and PartitionAccessPolicy.PublicRead is not a role — this fold ORs "
+ + "the public grant in after the deny subtraction, so there is nothing for the deny to take "
+ + "away. 🚨 This is the MEASURED behaviour and NOT the intended one: the SQL projection's "
+ + "own comment says a deny at a longer prefix wins, 'the store-gating shape and it is "
+ + "intentional'. So the two read paths disagree, which is the paywall-bypass shape — hidden "
+ + "from every listing, readable by exact path — and every deny written to protect a segment "
+ + "under such a policy is inert on THIS path (#4716). When the folds are reconciled this "
+ + "flips to BeFalse: change it deliberately, do not relax it to keep a run green");
+ (await CanRead("PolicyOpen/Cover", subject, cancellationToken)).Should().BeTrue(
+ "the control: the policy really is granting public read here, so the reading above is about "
+ + "the deny and not about a partition nobody could read in the first place");
+ }
+
+ ///
+ /// The second theory, and the reason the false rule looked true: where the public read comes from
+ /// root Viewer GRANTS instead of a policy, the SAME deny pair works — readability is a role, and a
+ /// deny removes it. This is the shape the rule was written from.
+ ///
+ [Theory(Timeout = 60_000)]
+ [InlineData(WellKnownUsers.Anonymous)]
+ [InlineData(WellKnownUsers.Public)]
+ public async Task ADenyUnderRootGrants_DoesWithholdRead(string subject)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ (await CanRead("GrantOpen/Cover", subject, cancellationToken)).Should().BeTrue(
+ "root Viewer grants inherit strictly downward — the cover and every ungated child are "
+ + "readable by everyone, which is what makes this a public partition at all");
+ (await CanRead("GrantOpen/Gated/Page", subject, cancellationToken)).Should().BeFalse(
+ "here the deny DOES hide the content: the read came from a role, so removing the role "
+ + "removes the read. Identical denies, opposite outcomes — the difference is whether the "
+ + "partition was opened with a policy or with grants, and that is the distinction the "
+ + "'a deny beats PublicRead' rule collapsed");
+ }
+
+ ///
+ /// The third theory: the one mechanism that suppresses an inherited public grant in THIS fold caps
+ /// every subject with it. So it cannot gate a segment somebody has to read — it darkens it for them
+ /// too, which is why "just cap the child" is not the remedy for the divergence above.
+ ///
+ [Fact(Timeout = 60_000)]
+ public async Task ADeeperReadCap_SuppressesThePublicGrantAndTheReviewerWithIt()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ (await CanRead("CapOpen/Gated/Page", WellKnownUsers.Anonymous, cancellationToken)).Should().BeFalse(
+ "a deeper Read=false cap is ANDed into the public grant (publicGrant &= scopeCap), so it is "
+ + "the only thing in THIS fold that withholds an inherited PublicRead");
+ (await CanRead("CapOpen/Gated/Page", Reviewer, cancellationToken)).Should().BeFalse(
+ "…and the SAME cap is ANDed into every role-derived permission, so the Viewer holding a "
+ + "real grant at the partition root loses Read as well. That is a BLACKOUT, not a gate: it "
+ + "cannot express 'closed to the public, open to the people who triage it', which is what a "
+ + "submission inbox under a public partition needs (#4716)");
+ (await CanRead("CapOpen/Gated/Page", WellKnownUsers.System, cancellationToken)).Should().BeTrue(
+ "the one identity the cap does not bind is System, which short-circuits the whole fold. A "
+ + "view that reads as the VIEWER — as the Feedback inbox does — therefore goes dark, so "
+ + "'just cap it' is not available as the fix");
+ }
+}