docs(#4716): a Public/Anonymous deny does not beat PublicRead in the C# fold — the SQL fold says it does - #4969
Conversation
…C# fold — the SQL fold says it does #4716 reports that `Feedback/_Policy` grants blanket `publicRead` over the partition collecting user submissions. The exposure is REAL and still live. Its proposed fix — a per-path deny overriding the partition grant, which the triage comment called "not hypothetical" on the strength of a `PackageInstaller` remark — is FALSIFIED. Measured, not argued: * memex.meshweaver.cloud, read-only, 2026-09-20 — `Feedback/_Policy` = `{ publicRead: true }`; `Feedback/_Submissions/demo-preview` exists under it; `Feedback/_Submissions/_Access/Public_Access` is `Not found` while `Feedback/_Access/Public_Access` reads back fine, so the absence is the node's and not a filtered read. * Monolith mesh — implementing the issue's fix (read the manifest's `protectedSegments`, write the Public+Anonymous Viewer denies) leaves the inbox PUBLIC. Both deny nodes existed (`exists …/_Access/Public_Access: True`, `…/Anonymous_Access: True`) and Anonymous still read the submission. `ComputeRoleState` subtracts denied ROLES and ORs `publicGrant` in separately and afterwards; `PublicRead` is not a role, so there is nothing to take away. * The only thing that withholds it in that fold is a deeper `Read = false`, whose cap is ANDed into every role-derived permission too — core's own `PublicReadPolicyScopeTest` already pins that a role holder loses Read. A blackout, not a gate, and the Feedback inbox reads as the VIEWER. * `PostgreSqlSchemaInitializer` (MeshWeaver.Plugins) states the OPPOSITE for its own path: "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 root cause is a two-executor divergence on the read path — the shape this evaluator's own comments call the paywall bypass (hidden from every listing, readable by exact path). Three components assert the rule only one path implements, and the third ACTS on it: the Store's `PluginGate` pre-installed arm writes exactly those denies for a manifest's `ProtectedSegments`, naming this very inbox, so the protection it applies is none on the C# path. This change set carries no behavioural fix — deliberately. Closing the divergence means folding the public grant longest-prefix over the well-known subjects' rows, and the state for it does not reach the fold today (`ComputeScopeRoles` filters assignments to the evaluated subject). That changes permission outcomes for every subject on every path, on a fold with an incident history on these exact lines, and the Postgres half is read from a comment rather than executed. It needs a decision, not a patch. What lands instead: * `PublicReadIsNotSuppressedByADenyTest` — the three shapes, executable: the deny is inert under a policy grant, works under root role grants (which is why the rule looked true), and the read cap darkens the reviewer with the public. * `Doc/Architecture/PublicReadAndDenies` — the whole measurement, the confidence on each half stated separately, and what each of the four remedies costs. * `PackageInstaller` — the false general rule corrected where it was read from, and the satellite delegation in `GatedChildRoots` annotated. * `AccessControl.md` — the parenthetical that reads as "a deeper deny still wins" disambiguated to *cap*, which is how the belief propagated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Security-sensitive behavior remains partially unverified, and regression coverage requires changes.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Documents and tests the divergence between C# and PostgreSQL permission folds for PublicRead denies without changing runtime behavior.
Changes:
- Adds regression coverage for policy grants, role grants, and read caps.
- Documents the divergence and potential remedies.
- Clarifies access-control documentation and installer comments.
- Registers the new architecture page.
| File | Summary |
|---|---|
test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs |
Adds permission-fold regression tests. |
src/MeshWeaver.PluginCatalog/PackageInstaller.cs |
Updates access-model documentation comments. |
src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md |
Documents the divergence and remediation options. |
src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md |
Clarifies deny versus cap behavior. |
src/MeshWeaver.Documentation/Data/Architecture.md |
Lists the new architecture page. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| **A `Public`/`Anonymous` Viewer DENY under a `PartitionAccessPolicy` that grants `PublicRead` is | ||
| honoured by the PostgreSQL read path and IGNORED by the C# `PermissionEvaluator`. The two read paths | ||
| disagree — which is the paywall-bypass shape, and the reason a submission inbox cannot currently be | ||
| closed.** |
There was a problem hiding this comment.
Correct, and it is the same trap this whole issue is an instance of — thank you. Fixing it rather than defending it.
The lead now reads: the C# behaviour is MEASURED; the PostgreSQL half is marked 🚨 a CLAIM, not a reading, sourced from the projection's comment with no portal exercised, and the reader is told to treat "hidden from every listing, readable by exact path" as the shape to go and confirm rather than an established contract. The "one line of SQL" section carries the same marker and names the failure mode explicitly — a stated convention is not a measured population — with the instruction to verify on a live Postgres portal before building any remedy on it.
That matters concretely because remedy 1 (reconcile the C# fold with the SQL one) is justified ONLY if the SQL side really denies; if it does not, the intended semantics are an open question rather than a divergence, and the remedy changes. The page now says so, and "verify the Postgres half first" is listed in What this page does not settle.
| /// Postgres projection does: it emits the policy as allow-<c>Read</c> rows at this prefix and | ||
| /// records that <i>"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"</i>. But |
There was a problem hiding this comment.
Agreed, and this is the more important of the two: this remark is exactly where the previous false general rule was READ FROM, so a second over-claim here would propagate the same way. Rewritten.
It now separates the two halves explicitly:
- What is FALSE on the C# read path — stated as measured, with the mechanism (
ComputeRoleStatesubtracts denied ROLES, then ORspublicGrantin separately) and the test that pins it. - What the Postgres projection CLAIMS — its comment quoted as the author's statement of intent, followed by: "That is its author's comment, NOT a test result: no Postgres path was executed for Feedback/_Policy grants blanket publicRead over the partition that collects user submissions (latent, not live) #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."
So the load-bearing guidance a later reader takes from this method is now the measured half only, and the unverified half is labelled as work to do. The part that holds regardless — that the deny PluginGate writes for a manifest's ProtectedSegments protects nothing on this path — is stated separately and does not depend on the SQL claim.
Test Results (shard 4)0 tests 0 ✅ 0s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)511 tests 511 ✅ 25s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)396 tests 396 ✅ 55s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
Test Results (shard 0) 1 files 1 suites 2m 54s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 621 tests 1 621 ✅ 2m 38s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
Test Results (shard 2) 1 files 1 suites 5m 29s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
Test Results 6 files 6 suites 12m 23s ⏱️ Results for commit 617b5d1. ♻️ This comment has been updated with latest results. |
…easurement Both Copilot findings are the same defect and they are right: the lead and the installer remark asserted the PostgreSQL behaviour as fact while the confidence section of the same page says that path was never executed. On a security contract that is how a later fix comes to trust an unvalidated half — and it is the same "a stated convention is not a measured population" trap #4716 is itself an instance of. * The doc lead now states the C# half as measured and marks the SQL half 🚨 a CLAIM sourced from the projection's comment, with "hidden from every listing, readable by exact path" named as the shape to go and CONFIRM. * The "one line of SQL" section repeats the marker, names the failure mode, and says to verify on a live Postgres portal before building a remedy on it — which matters because remedy 1 is justified only if SQL really denies. * `PackageInstaller`'s remark — the place the previous false rule was READ FROM, so the one that must not over-claim — now separates the measured C# half from the projection's CLAIM, and keeps the conclusion that does not depend on it: the deny `PluginGate` writes for `ProtectedSegments` protects nothing there. * The test header no longer calls the divergence itself measured. Comment-only in `src/`; 17/17 doc guards green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ever adds one Found by reading the CONTROL instance's copy, which the issue and #4969 both measured only on memex.meshweaver.cloud. On memex.systemorph.com (read-only, 2026-09-21) `Feedback/_Policy` carries no `publicRead` and the partition holds no `_Submissions` at all — so the declaration-driven arm, as first written, would have added root Public+Anonymous Viewer GRANTS and PUBLISHED a partition whose policy withheld public read. A widening introduced by the fix for an exposure is the worst shape a security change can take, and `main` does not do it: create-only leaves such a policy alone. So `PublishExceptProtected` now takes `publish`, and the two arms pass it the condition under which this step would have published anyway — no policy yet (its original create), a policy that declares `PublicRead`, or the legacy fingerprint the heal deliberately opens on. Everywhere else only the DENIES are written, which can only narrow. `ADeclaredProtectedSegment_OnAPartitionThatWithholdsPublicRead_IsGatedButNotOpened` pins it: the segment is gated, no root grant appears, the policy is untouched. Reverting the rule fails it on `ClosedPkg/_Access/Public_Access` — the grant it must not write. Refs #4716. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#4969, whose doc page and test this change set now builds on
…ms this change supersedes The merge of origin/main was textually clean, which is exactly the risk: #4969 left three prose claims that the code under them no longer matches, and a clean merge would have shipped all three. 1. "That is its author's comment, NOT a test result … 'the two read paths disagree' is the thing to go and confirm" — CONFIRMED. The projection emits the policy as allow-Read rows for Public/Anonymous at the prefix (the same row shape a root grant produces) and three independent read-side emitters resolve `DISTINCT ON (user_id) … ORDER BY LENGTH(node_path_prefix) DESC`, so a deny at a longer prefix wins; that half is executable SQL and is already pinned against a real Postgres in MeshWeaver.Plugins by PerSubjectAccessFoldTests and AccessControlQueryTests.PaywalledContent_StaysInvisibleToAnonymous. Core still executes no Postgres case — it has no lane — so this stays a read of another repository's SQL plus its tests. 2. "Order matters: denies first, policy last" — true for the legacy SWEEP, and the opposite of what the grant shape does, because opening a partition and retiring a gate fail closed in opposite directions. Both orders now stand, each naming why it differs from the other. 3. "That delegation is sound for THIS shape and a real gap for the other one" — that gap is what this change set closes, and it closes it the way #4969's own last sentence demanded: by reading the DECLARATION, never by gating satellites from the `_` prefix. The prohibition stands and is now cited as the reason for the shape. Also cross-links the two doc pages, stating plainly that PublicReadIsNotSuppressedByADenyTest is expected to keep measuring the divergence — nothing here closes it; this stops depending on it. Verified after the merge, each `-c Release -warnaserror` at 0 warnings / 0 errors: MeshWeaver.PluginCatalog, MeshWeaver.Documentation, Memex.Portal.Shared, MeshWeaver.Documentation.Test, MeshWeaver.Graph.Test, Memex.Portal.Shared.Test, MeshWeaver.Layout.Test, MeshWeaver.Compiler.Pipeline.Test. Tests: AnonymousCannotReadAProtectedSubmissionTest + #4969's PublicReadIsNotSuppressedByADenyTest + PublicReadPolicyScopeTest together 24/24; the whole Memex.Portal.Shared.Test project 1765 total, 0 failed; the four documentation guards 17/17. Refs #4716. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Refs #4716 (
sev:H,area:security). The issue's exposure is real and still live. Its proposed fix is falsified. No behavioural change lands here — see "Why no fix lands yet".What I measured
On
memex.meshweaver.cloud, read-only, 2026-09-20 — the issue's own claim, re-verified:Feedback/_PolicyPartitionAccessPolicy { publicRead: true },lastModifiedBy: system-security, v198193 — written that morning, not "unwritten since 2026-08-12" as the issue saysFeedback/_Submissions/demo-previewnodeType: Feedback/Feedback— the demo item, so still latentFeedback/_Submissions/_Access/Public_AccessFeedback/_Access/Public_AccessPublic — Viewer, reads back fine → the absence above is the node's, not a filtered readThe manifest states the intent outright:
"preInstalled": trueand"protectedSegments": ["_Submissions"].In a monolith mesh — I implemented the issue's option 2 (read
protectedSegments, write the Public+Anonymous Viewer denies before opening the partition) and it does not work:PermissionEvaluator.ComputeRoleStatesubtracts denied roles fromroleIdsand ORspublicGrantin separately and afterwards. A deny removes a ROLE;PublicReadis not a role, so there is nothing for it to take away.The only thing that does withhold it there is a deeper
Read = false— whose cap is ANDed into every role-derived permission too. Core's existingPublicReadPolicyScopeTestalready pins that cost (RolePolicy/Capped/Pagedenies a real Viewer). A blackout, not a gate — and the Feedback inbox reads as the viewer, so it would go dark for the people who triage it.The root cause: the two read paths disagree
PostgreSqlSchemaInitializer(MeshWeaver.Plugins) states the opposite for its own path:So a deeper deny beating an inherited public grant is the intent, SQL implements it, and the C# evaluator does not. That is the shape this evaluator's own comments call the paywall bypass — hidden from every listing, readable by exact path. It bit once already (2026-08-05, 79,650 characters of paid course content).
Three components assert the rule only one path implements, and the third acts on it:
PackageInstaller.EnsurePartitionPublicRead's remarks stated it as a general rule; #4716's triage cited those remarks; and the Store'sPluginGatepre-installed arm writes exactly those denies for a manifest'sProtectedSegments, under a comment naming this inbox. On the C# path it is a protection that cannot protect. It is also absent from the live partition, so the exposure persists until it lands — independently.Core additionally never reads
protectedSegmentsat all (NodeRepoPackageSource.Peekdrops it), so the installer could not honour the declaration even given a mechanism.Negative control
There is no behavioural fix here, so there is no fix to revert — and I am not claiming a conventional negative control. What I ran is the inverse and it is stronger: I built the issue's proposed fix and it failed, with the readings above. The deny nodes provably existed and the read was still
Read.The first theory of
PublicReadIsNotSuppressedByADenyTestis that same measurement in deterministic static-node form. It is the test that goes red the day the divergence is closed — which is stated in the file, so nobody relaxes it to keep a run green.Why no fix lands yet (a scope call, not a stall)
Closing the divergence means making
publicGranta longest-prefix chain over the well-known subjects' rows instead of an OR-accumulator. The state for that does not reach the fold:ComputeScopeRolesfilters assignments to the evaluated subject, so a Public/Anonymous deny is absent whenever the viewer is somebody else. So it widens the snapshot a long-lived reactive fold consumes, and changes permission outcomes for every subject on every path — on code with an incident history on these exact lines (#974 fail-open; the 2026-08-05 bypass; a 2026-09-11 correction whose four failures were all "reads through a deeper read cap"). And the Postgres half is read from a comment, not executed.That wants a decision and a Postgres verification first, so I stopped rather than guessing at the access model. The four candidate remedies and what each costs are written up in the doc page.
What lands
PublicReadIsNotSuppressedByADenyTest(test/MeshWeaver.Graph.Test/) — the three shapes, executable: the deny is inert under a policy grant; it works under root role grants (which is exactly why the rule looked true); the read cap darkens the reviewer along with the public. 5 cases.Doc/Architecture/PublicReadAndDenies— the full measurement, confidence stated separately per half, the four remedies costed, and what the page deliberately did not establish.PackageInstaller— the false general rule corrected where it was read from; theGatedChildRootssatellite delegation annotated as sound for one shape and a gap for the other. Comment-only: the diff is 100%///lines.AccessControl.md— the parenthetical that reads as "a deeper deny still wins" disambiguated to cap, which is how the belief propagated, plus a pointer to the new page.Verified locally
dotnet build -c Release -warnaserror, one project per invocation:MeshWeaver.PluginCatalog,MeshWeaver.Documentation,MeshWeaver.Graph.Test,Memex.Portal.Shared.Test— each0 Warning(s) 0 Error(s).PublicReadIsNotSuppressedByADenyTest— 5/5 pass.DocumentationLinkIntegrityTest,DocumentationFrontMatterGuard,DocumentationEmbedIntegrityTest,ArchitectureTopicMapTest— 17/17 pass.ArchitectureTopicMapTestcaught the unlisted page and the link gate caught two sibling links; both fixed.No public surface removed or added, no interface members, no i18n keys, no package versions — so no
Pairs-with:,Implementers:,Mirror-sync:orSatellite-pins:declaration applies.Not merging
Per the fleet's bug-triage rule the merge is a human's signature: this is opened, reviewed and left for you. #4716 should stay open — this change set documents and falsifies, it does not close the exposure.
🤖 Generated with Claude Code