Skip to content

docs(#4716): a Public/Anonymous deny does not beat PublicRead in the C# fold — the SQL fold says it does - #4969

Merged
rbuergi merged 2 commits into
mainfrom
fix/4716-protected-segments-are-dead-metadata
Sep 21, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/4716-protected-segments-are-dead-metadata

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

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:

Read Result
Feedback/_Policy PartitionAccessPolicy { publicRead: true }, lastModifiedBy: system-security, v198193 — written that morning, not "unwritten since 2026-08-12" as the issue says
Feedback/_Submissions/demo-preview exists, nodeType: Feedback/Feedback — the demo item, so still latent
Feedback/_Submissions/_Access/Public_Access Not found — no deny exists
Feedback/_Access/Public_Access Public — Viewer, reads back fine → the absence above is the node's, not a filtered read

The manifest states the intent outright: "preInstalled": true and "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:

exists GuardedPkg/_Submissions/_Access/Public_Access: True
exists GuardedPkg/_Submissions/_Access/Anonymous_Access: True
Anonymous on GuardedPkg/_Submissions/entry: Read      ← still public

PermissionEvaluator.ComputeRoleState subtracts denied roles from roleIds and ORs publicGrant in separately and afterwards. A deny removes a ROLE; PublicRead is 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 existing PublicReadPolicyScopeTest already pins that cost (RolePolicy/Capped/Page denies 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:

Runs AFTER the policy-cap deny fold … so at the SAME prefix the public grant wins. (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 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's PluginGate pre-installed arm writes exactly those denies for a manifest's ProtectedSegments, 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 protectedSegments at all (NodeRepoPackageSource.Peek drops 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 PublicReadIsNotSuppressedByADenyTest is 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 publicGrant a longest-prefix chain over the well-known subjects' rows instead of an OR-accumulator. The state for that does not reach the fold: ComputeScopeRoles filters 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; the GatedChildRoots satellite 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 — each 0 Warning(s) 0 Error(s).
  • PublicReadIsNotSuppressedByADenyTest — 5/5 pass.
  • The guards covering what I added: DocumentationLinkIntegrityTest, DocumentationFrontMatterGuard, DocumentationEmbedIntegrityTest, ArchitectureTopicMapTest — 17/17 pass. ArchitectureTopicMapTest caught 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: or Satellite-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

…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>

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.

Copilot review overview

🔵 Needs a closer look

Security-sensitive behavior remains partially unverified, and regression coverage requires changes.

Review effort: Lite
Findings: 2 Low severity

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.

Comment on lines +10 to +13
**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.**

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +1078 to +1080
/// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 (ComputeRoleState subtracts denied ROLES, then ORs publicGrant in 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.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit 617b5d1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

511 tests   511 ✅  25s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 617b5d1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

396 tests   396 ✅  55s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 617b5d1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files    1 suites   2m 54s ⏱️
294 tests 294 ✅ 0 💤 0 ❌
296 runs  296 ✅ 0 💤 0 ❌

Results for commit 617b5d1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 621 tests   1 621 ✅  2m 38s ⏱️
    2 suites      0 💤
    2 files        0 ❌

Results for commit 617b5d1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

    1 files      1 suites   5m 29s ⏱️
1 873 tests 1 873 ✅ 0 💤 0 ❌
1 874 runs  1 874 ✅ 0 💤 0 ❌

Results for commit 617b5d1.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Test Results

    6 files      6 suites   12m 23s ⏱️
4 695 tests 4 695 ✅ 0 💤 0 ❌
4 698 runs  4 698 ✅ 0 💤 0 ❌

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>
@rbuergi
rbuergi disabled auto-merge September 20, 2026 09:48
@rbuergi
rbuergi added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 9498c24 Sep 21, 2026
37 checks passed
rbuergi added a commit that referenced this pull request Sep 21, 2026
…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>
rbuergi added a commit that referenced this pull request Sep 21, 2026
…#4969, whose doc page and test this change set now builds on
rbuergi added a commit that referenced this pull request Sep 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants