fix(#4716): a submission inbox on a public partition — the grant shape both permission folds agree on, and a boot heal that stopped deleting it - #5043
Conversation
… and the boot heal stops deleting it
`Feedback` is pre-installed and its manifest declares `protectedSegments: ["_Submissions"]` — a
submission inbox on an otherwise public partition. Core could not see that declaration at all
(`NodeRepoPackageSource.Peek` dropped it: the dead-metadata defect class `preInstalled`,
`publicSegments` and `contactEmail` each had), and two consequences followed, both core's.
**The partition took the fully-public shape.** Under `PartitionAccessPolicy { PublicRead = true }`
the C# evaluator ORs the public grant in AFTER the per-subject deny subtraction, and PublicRead is
not a role — so every Public/Anonymous deny protecting the inbox is INERT on that path, while the
SQL projection's longest-prefix fold honours it. One segment, two answers: the paywall-bypass
shape, readable by exact path and correctly absent from every listing.
`AnonymousCannotReadAProtectedSubmissionTest` measures both sides of that.
**And the legacy heal DELETED the protection.** `ContradictingDenies` matched the plugin
machinery's own deny pair as pre-#902 damage, so a boot repair pass retired them and rewrote the
policy to PublicRead — logged at Information as a legacy migration.
The fix:
- `PackageManifest.ProtectedSegments`, read by `Peek` — core can see the declaration.
- A partition declaring one is published through root Public+Anonymous Viewer GRANTS with a deny on
each declared segment and a `_Policy` that WITHHOLDS PublicRead. Grants and denies are ROLE rows,
which the C# evaluator and the SQL fold resolve by the same longest-prefix rule, so the shape
cannot answer two ways. The catalog half stays world-readable; the reviewer keeps their own grant;
System (which every submit path impersonates) is untouched, so the inbox stays submittable.
- The heal's fingerprint is narrowed BY CONSTRUCTION: a deny on an `_` satellite scope is never
retired, because `GatedChildRoots` skips every `_` segment and so no version of this installer
could have written one.
- An EVIDENCE arm for the live fleet: a satellite deny this installer cannot have written takes the
partition off the blanket policy even when the manifest says nothing — the boot repair pass drives
the re-assert from the install RECORD, and every record stamped before the field was read carries
no declaration. Without it every already-installed portal republishes its inbox on the next boot.
- The two rules compose: genuine pre-#902 damage on ordinary children is still retired.
Refs #4716.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h permission folds agree on The durable form of #4716's finding: why a partition that is public EXCEPT for one inbox cannot be expressed with `PartitionAccessPolicy.PublicRead`, what each candidate remedy costs, and what the change set measured versus what it could not. - New page under `Data/Architecture/`, listed in the topic map. - `AccessControl.md`: the "Public policy grants and deeper read caps" section is about a read CAP, and its closing sentence — "role denies continue to remove roles, while PublicRead remains a separate grant" — is exactly why a deeper role DENY under such a policy is inert on the C# path while the SQL fold honours it. Qualified in place, with a pointer. - The test-side precondition now carries the partition's `_Policy`, which is the live shape on memex.meshweaver.cloud: without it `current` is null in the heal, the retire arm is never reached, and the assertion on the denies surviving passes for the wrong reason. Refs #4716. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 3)519 tests 519 ✅ 32s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)396 tests 396 ✅ 46s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
Test Results (shard 0) 1 files 1 suites 2m 36s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 624 tests 1 624 ✅ 2m 41s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
…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>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 5
Open (8)
Align declared marker with protected package shape · New Make policy transition atomic and fail closed · New Reject conflicting existing assignments in CreateOnly · New Use RunAsSystem boundary for repair query · New Wait for Initial query snapshot before evaluating evidence · New Add discovery test for protectedSegments projection · New Correct XML documentation for protected-segment policy marker · New Use immutable collections for mesh node sequences · New
What changed in this PR
Fixes protected child segments on public package partitions by propagating protectedSegments, using grant/deny access shapes, and preserving protections during boot repair.
Changes:
- Adds manifest parsing and grant-based publication.
- Narrows legacy deny healing.
- Adds regression tests and architecture documentation.
| File | Description |
|---|---|
| test/MeshWeaver.Graph.Test/AnonymousCannotReadAProtectedSubmissionTest.cs | Updated as part of this pull request. |
| test/Memex.Portal.Shared.Test/ADeclaredProtectedSegmentSurvivesTheBootHealTest.cs | Updated as part of this pull request. |
| src/MeshWeaver.PluginCatalog/PackageInstaller.cs | Updated as part of this pull request. |
| src/MeshWeaver.PluginCatalog/Package.cs | Updated as part of this pull request. |
| src/MeshWeaver.PluginCatalog/NodeRepoPackageSource.cs | Updated as part of this pull request. |
| src/MeshWeaver.Documentation/Data/Architecture/ProtectedSegmentsOnAPublicPartition.md | Updated as part of this pull request. |
| src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md | Updated as part of this pull request. |
| src/MeshWeaver.Documentation/Data/Architecture.md | Updated as part of this pull request. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return declared.Count > 0 | ||
| ? EnsureScopedPublicRead( | ||
| hub, manifest, partition!, declared, isolated, installedPaths, logger) |
There was a problem hiding this comment.
Accepted — real, and it is the exact trap my own remarks on DeclaredAccessMarker warn about ("a marker naming a node the chosen shape does not write turns the post-condition into an error on a correct install"), so I wrote the warning and then walked into it.
The case is a pre-installed manifest declaring BOTH publicSegments and protectedSegments: EnsureDeclaredAccess routes it to EnsureScopedPublicRead, which writes root grants and no policy, while the marker still names {partition}/_Policy. VerifyDeclaredAccess would then report a correct install as a failure.
Fixing it by making the marker mirror the selection exactly, rather than by restating it: the grant marker whenever declaredPublic.Count > 0 && (protectedPaths.Count > 0 || !PreInstalled) — the same predicate the branch uses. A pinned case for the both-declared pre-installed manifest goes in beside it, because that combination is what nothing covered.
Worth stating for the record: EnsureOpenWithProtectedSegments (protected only, no publicSegments) does keep _Policy as its marker, and that is safe in both of its arms — when it publishes it writes the policy, and when it declines to publish (create-only) it is because a policy already exists.
| return CreateOnly(hub, denies) | ||
| .SelectMany(deniesWritten => policyWrite | ||
| .SelectMany(_ => CreateOnly(hub, grants)) | ||
| .Do(grantsWritten => report(deniesWritten, grantsWritten, rewritePolicy))) |
There was a problem hiding this comment.
Accepted, and you are right that the property I documented is not the property the order delivers.
The remarks claim "the worst interruption leaves the PUBLIC half dark". That holds on a fresh partition, and it does NOT hold on the transition that actually matters: _Policy { PublicRead = true } already present, denies written, crash before the flip — the old public grant is still live and the new denies are inert on the C# path, i.e. exactly the state the change exists to end.
Reordering to policy flip -> denies -> root grants, which is fail-closed at every point: the flip removes the blanket grant while no root grant exists yet, so the partition is momentarily closed rather than momentarily open; the denies land while it is closed; the grants re-open only the public half, with the gate already in place. A crash anywhere leaves the partition closed, which is the half it is safe to fail on, and the next pass completes it.
I am not taking the other half of the suggestion — an atomic transition or a staged state readers fail closed on. There is no multi-node atomic write here, and introducing a new access state for this would be a new permission primitive on the fold with the incident history; the reorder achieves fail-closed with no new concept, so adding one would be cost without a gain. Saying so explicitly rather than silently doing half.
| return CreateOnly(hub, denies) | ||
| .SelectMany(deniesWritten => policyWrite | ||
| .SelectMany(_ => CreateOnly(hub, grants)) | ||
| .Do(grantsWritten => report(deniesWritten, grantsWritten, rewritePolicy))) |
There was a problem hiding this comment.
Accepted — real, and it is the one way the shape could be defeated silently.
CreateOnly asks only "is a node there", so a Public/Anonymous assignment at a declared-protected scope that is a GRANT (or grants any read-capable role) survives, and the root grant then publishes the segment. The protection would read as established and not be.
The house rule for this already exists next door: the Store gate does not use presence either, it uses StillNeedsDeny (fresh is null || !IsDenyAssignment(fresh)). Adopting the same predicate for the protected-segment denies via the existing IsWellKnownDeny: write when absent, and write when the existing assignment for that well-known subject is not an all-denied role set.
That is deliberately narrow and it does not breach create-only, because it can only ever NARROW: the only node it overwrites is a Public/Anonymous assignment at a scope the manifest declares protected, and it replaces it with a deny. Every other assignment — a real user, a group, any grant at any other scope — stays untouched. A case with a pre-existing Public grant at the protected scope goes in with it.
| @@ -1208,19 +1449,42 @@ private static IObservable<IReadOnlyList<string>> ContradictingDenies( | |||
| + $"nodeType:{AccessAssignmentNodeType.NodeType} limit:{QueryLimit}"))) | |||
There was a problem hiding this comment.
Declining this one, with the reasoning rather than a dismissal — and flagging where I think the finding is misaddressed.
That Observable.Using(() => ImpersonateAsSystem(), ...) is not new in this diff. The method was ContradictingDenies; I changed its .Select projection (splitting one deny list into two) and its return type, and left the impersonation and the query untouched. git diff origin/main...HEAD on the file shows the Observable.Using line as unchanged context.
I also do not want to fix it here, because a partial pattern fix is worse than none: the same Observable.Using(ImpersonateAsSystem) shape is used by Retire and by the scoped-shape child walk in this same file, and by call sites elsewhere. Converting one of them leaves the belief "this file uses RunAsSystem" while two sites still do not, which is how the next reader stops checking. If the AsyncLocal-restore-on-the-wrong-flow concern is right it is a sweep of its own, with its own measurement of what actually latches.
What I have checked and what I have not: AccessService.RunAsSystem does exist as the sealed boundary, so the remedy you name is available. I have NOT reproduced a wrong-flow restore on this path, and I am not claiming the concern is wrong — only that it is pre-existing and wider than this diff.
| .Select(change => | ||
| { | ||
| var denies = change.Items.Where(node => IsWellKnownDeny(node, hub)).ToList(); |
There was a problem hiding this comment.
Accepted — this is the strongest of the eight, because it is a fail-OPEN direction on a decision that is now a security decision.
If the consumed frame omits the satellite deny, found.Protected is empty, the evidence arm does not fire, and the pre-installed heal proceeds to retire denies and write PublicRead — republishing the protected segment, which is the defect the arm exists to prevent. That the pre-existing code already took a security decision off the same unfiltered .Take(1) (the legacy retire DELETES nodes on it) makes it worse, not acceptable.
Filtering for the Initial snapshot before Take(1). It is the established idiom in this repository rather than something new — eight sites already do it, including DeploymentReportService, PlanTierLadder, GitHubSyncService and PathResolutionService — so the fix is .Where(change => change.ChangeType == QueryChangeType.Initial) ahead of the existing .Take(1), and the .Timeout stays where it is so a snapshot that never arrives is a diagnosed failure rather than a hang.
Note the failure mode after the fix is the one I want: a listing that cannot produce an Initial frame times out into the existing Catch, which yields nothing and leaves the partition exactly as it is. No retire, no policy write.
| // pre-installed partition declaring `_Submissions` got the fully-public | ||
| // policy and core's legacy-gate heal then retired the Store's own denies on | ||
| // the next boot (MeshWeaver#4716). | ||
| ProtectedSegments = peeked.ProtectedSegments, |
There was a problem hiding this comment.
Accepted — and the observation is sharper than "add a test": it names how the whole change set could regress into being a no-op while every assertion I wrote stays green.
Every test I added constructs PackageManifest in-process, so the projection under review — the nested content.protectedSegments read in Peek, the PeekedRoot field, and the assignment into the manifest — is covered by nothing. That is precisely the shape of the defect being fixed: the field existed in the authored index.json and core dropped it on the way in, silently, and installed node-repo packages took the blanket PublicRead path. A regression there restores the exposure and reds nothing.
Adding a source-listing test that goes through NodeRepoPackageSource.ListPackages with an injected fetch (the constructor takes the fetch delegate, so no network and no git), over an index.json that carries protectedSegments beside preInstalled, asserting the declaration survives discovery — and one arm asserting a manifest without the key comes back empty rather than defaulted to something.
| /// readable.</item> | ||
| /// <item>🚨 <b>Anything declaring <see cref="PackageManifest.ProtectedSegments"/></b> — public | ||
| /// EXCEPT the declared segments, published through root Public+Anonymous Viewer GRANTS with a | ||
| /// Public+Anonymous DENY on each declared segment, and NO <c>_Policy</c> |
There was a problem hiding this comment.
Accepted — my own inconsistency, introduced when the design moved mid-change.
The shape originally wrote no _Policy at all. It acquired one precisely so DeclaredAccessMarker keeps holding (and so the partition carries the decision rather than leaving the next reader to infer it from the grants), and I updated the method that writes it without going back to the two places that describe it. So the documentation now says the opposite of the code, on exactly the node a future change must not remove.
Correcting both — the EnsureDeclaredAccess bullet and the EnsureOpenWithProtectedSegments summary — to state the actual shape: root Public+Anonymous Viewer grants, a deny per declared segment, and a _Policy that WITHHOLDS PublicRead, which is the install post-condition marker and must stay. Naming the marker dependency in the text, since that is the part that bites if it is removed.
| var denies = new List<MeshNode>(); | ||
| foreach (var scope in protectedScopes) | ||
| { | ||
| denies.Add(ViewerAssignment(scope, WellKnownUsers.Public, denied: true)); | ||
| denies.Add(ViewerAssignment(scope, WellKnownUsers.Anonymous, denied: true)); | ||
| } | ||
| var grants = new List<MeshNode> | ||
| { | ||
| ViewerAssignment(partition, WellKnownUsers.Public, denied: false), | ||
| ViewerAssignment(partition, WellKnownUsers.Anonymous, denied: false), | ||
| }; |
There was a problem hiding this comment.
Accepted. new List<MeshNode>() in src/ is against the repository collection policy, and I introduced two of them in PublishExceptProtected while removing a pre-existing one from the scoped shape — so the diff moved the violation rather than clearing it.
Building both sequences with LINQ into ImmutableArray instead: the denies as a SelectMany over the protected scopes producing the Public/Anonymous pair, the grants as an ImmutableArray.Create of the two root assignments. CreateOnly already takes IEnumerable<MeshNode>, so nothing else changes, and the scoped shape next door already composes its sequence this way after this diff — which makes the two shapes read the same.
Test Results (shard 2) 1 files 1 suites 5m 16s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
Test Results (shard 4) 1 files 1 suites 6m 47s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
Test Results 7 files 7 suites 18m 40s ⏱️ Results for commit b94cb93. ♻️ This comment has been updated with latest results. |
… own shape, an order that was not fail-closed, and a deny decided by presence
All eight Copilot findings answered on their threads; seven accepted, one declined with reasoning.
**The marker contradicted the shape it documents.** A pre-installed manifest declaring BOTH
`publicSegments` and `protectedSegments` routes to `EnsureScopedPublicRead`, which writes root grants
and no policy — while `DeclaredAccessMarker` still named `{partition}/_Policy`, so
`VerifyDeclaredAccess` would have reported a CORRECT install as a failure. The marker now MIRRORS the
branch predicate rather than restating it, which is the rule its own remarks state.
**The order was not fail-closed on the one transition that matters.** With an existing
`PublicRead = true` policy, denies-then-policy leaves the blanket grant live and the fresh denies
inert if it fails in between — the exact state the change exists to end, while the remarks claimed the
opposite property. Now policy → denies → root grants: the flip removes the grant while no root grant
exists, so the partition is momentarily CLOSED, and a failure anywhere leaves it closed.
**A deny decided by PRESENCE is not a deny.** `CreateOnly` skipped any existing node, so a
`Public`/`Anonymous` assignment at a declared-protected scope that happened to be a GRANT survived and
the root grant then published the segment. The protected-segment denies now supersede an existing
assignment for those two subjects that is not already all-denied — the Store gate's `StillNeedsDeny`
predicate. It can only narrow, and it is deliberately not applied to the scoped shape's ordinary-child
gating.
**Both evidence decisions now read the `Initial` snapshot**, not the query's first emission: a
pre-`Initial` frame that omits the satellite deny leaves the protected set empty and the heal proceeds
to retire denies and write `PublicRead`. The legacy retire had been taking a DELETE decision off the
same unfiltered read.
Also: `AProtectedSegmentDeclarationSurvivesDiscoveryTest` covers the `index.json` →
`NodeRepoPackageSource.ListPackages` → manifest hop that no other test touches (every other one builds
the manifest in-process, so core dropping the field again would red nothing); a marker case for the
both-declaring pre-installed manifest; the two XML doc statements that still said the shape writes NO
`_Policy` after it began writing one; and `ImmutableArray` in place of the two `List<MeshNode>`.
DECLINED: converting this file's `Observable.Using(ImpersonateAsSystem)` to `RunAsSystem`. That line is
unchanged by this diff, and the same shape is used by `Retire` and the scoped child walk — converting
one leaves the belief that the file uses the sealed boundary while two sites do not. It is a sweep of
its own, with its own measurement.
Refs #4716.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er needed a fail-closed half The review's `Initial`-snapshot filter is right about the fail-open, and on its own it would have been WORSE than the read it replaced. A finding already in the fleet's triage inbox names why, with the verbatim line: Query provider(s) [StorageAdapterMeshQueryProvider] have not emitted an Initial after 20s for query 'path:Chess scope:subtree nodeType:AccessAssignment limit:2000' (user 'system-security') That is THIS query's exact shape and identity — 200+ occurrences in a 400-minute window on memex, truncated at the log limit, because `DefersToNativeProvider` is false for satellite reads and the pedestrian walk emits nothing until every per-path read completes. So on a Postgres portal the Initial can genuinely never arrive, and a filter that turned that into "the read says there are no denies" fails open DETERMINISTICALLY rather than occasionally. `WellKnownDenies` therefore answers a tri-state, and every arm of the caller declines on `Ok: false`: nothing retired, and no blanket PublicRead policy written over a shape the pass could not see. That restores a rule this method's own remarks had always stated — "healing on an unknown deny set is the one outcome worse than not healing" — which the original-create arm had quietly broken by writing the policy without consulting the listing at all. A host with no query surface is deliberately a DIFFERENT answer (`Ok: true`, empty): nothing to enumerate is not the same as not knowing. 🚨 That one `if` is NOT covered by a test — forcing the listing to fail or to withhold its Initial needs a query-provider fault seam this suite has no hook for, so it is reasoned from the production measurement rather than executed. Said so on the doc page rather than leaving it to look covered. Verified: MeshWeaver.PluginCatalog, MeshWeaver.Documentation and Memex.Portal.Shared.Test each build `-c Release -warnaserror` with 0 warnings and 0 errors; the whole Memex.Portal.Shared.Test project — the suite that owns EnsureDeclaredAccess — is 1765 total, 0 failed; the four documentation guards 17/17. 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>
…ew caught twice `EnsureOpenWithProtectedSegments` still said "Denies first, root grants last — the same ordering rule as the scoped shape" after the order was changed to policy → denies → root grants, and after the remarks on `PublishExceptProtected` explained at length why the scoped shape's rule is NOT fail-closed here. Two of the eight review findings on this pull request were exactly this — a comment asserting the opposite of the code beneath it — so a sweep of my own additions was owed rather than assumed. Swept the rest of the file for the same shape: the "an explicit deny beats PublicRead" sentence is already marked as the corrected claim it is, and nothing else still describes a `_Policy` that is not written or an order that is not taken. Refs #4716. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>



Refs #4716 (
sev:H,area:security). The half #4969 deliberately left open: the policy on the Feedback partition, and the fold asymmetry — which is real, and is now confirmed in both directions rather than asserted in one. #4969 documents and falsifies; this closes it, and does not touch its files' hunks.1. What
Feedback/_Policydeclares — shipped, and liveShipped: nothing. There is no
_Policynode anywhere in theFeedbackpackage tree (MeshWeaver.Plugins/Feedback/). The node is MINTED at install time byPackageInstaller.EnsureDeclaredAccess, and the manifest is the whole declaration:{ "preInstalled": true, "protectedSegments": ["_Submissions"] }Feedbackis the only package in the fleet that declaresprotectedSegments(one hit acrossMeshWeaver.Plugins/*/index.json).Live on memex.meshweaver.cloud, read-only, sampled twice minutes apart — identical both times:
Feedback/_Policy{"$type":"PartitionAccessPolicy"}— nopublicRead· v198207 ·lastModified 2026-09-20T22:32:06.197014Z·system-securityFeedback/_Access/{Public,Anonymous}_AccessPublic — Viewer/Anonymous — Viewer— root role GRANTS, from 2026-07-24Feedback/_Submissions/_Access/Public_AccessPublic — Viewer DENIED (gated)· version 1 · created2026-09-20T22:32:07.829904ZFeedback/_Submissions/_Access/Anonymous_Access2026-09-20T22:32:08.001704ZFeedback/_Submissionschildrendemo-preview(v1, 2026-07-21) — the demo itemAnd on the CONTROL instance memex.systemorph.com, read-only, the same day:
Feedback/_Policyalso carries nopublicRead(v184,lastModified 2026-09-21T03:27:26Z,system-security), and that partition holds no_Submissionsat all — no inbox, no denies. That second reading is what caught a widening in my own first attempt; see §4.So the live state moved between #4969's reading and mine. #4969 measured
publicRead: trueat v198193 on 2026-09-20; at 22:32:06Z that same day the policy was rewritten without it and the_Submissionsdeny pair was created 1.6 s later — the write order and the node names arePluginGate.SeedGating's pre-installed arm exactly ("close, then open"). The exposure is therefore not open right now, and the inbox still holds only the demo item, so nothing a user wrote was ever public: latent, and now also closed — by live state alone.2. Is the exposure real? Yes — measured, in both folds
C# fold — executed.
AnonymousCannotReadAProtectedSubmissionTest(new,test/MeshWeaver.Graph.Test/, real monolith mesh,ConfigureMeshBaseso no Public Admin) seeds the two shapes side by side, differing in ONE node:OpenPkg—_Policy { PublicRead = true }+ the declared deny pair on the inbox satellite.AnonymousandPublicboth READOpenPkg/_Submissions/theirs. The protection is present, intact, and does nothing.GatedPkg— root Public+Anonymous Viewer GRANTS +_Policy { PublicRead = false }+ the same deny pair. Both are DENIED, the cover stays public, and the reviewer andSystemkeep reading.Each half carries its own public-half control, so a denial cannot pass for the right answer on a partition nobody could read. 5 cases, all passing.
SQL fold — NOT executed here, and it cannot be: core has no Postgres test lane at all (no
Testcontainers/Npgsqlreference in any core test.csproj; the one declared version is consumed by nothing). What replaces the measurement is executable SQL plus tests that already run inMeshWeaver.Plugins:PostgreSqlSchemaInitializerprojects aPublicReadpolicy as allow-Readrows forPublic/Anonymousat the policy's prefix — the same row shape a root grant produces.DISTINCT ON (user_id) … ORDER BY LENGTH(node_path_prefix) DESCin three independent emitters (PostgreSqlSqlGenerator.BuildPerSchemaAccessClause,GenerateAccessControlClause,public.search_across_schemas), so a deny row at a longer prefix wins.AccessControlQueryTests.PaywalledContent_StaysInvisibleToAnonymousandPerSubjectAccessFoldTestspin that deeper-deny-beats-shallower-public-grant behaviour against a real Postgres.So #4969's "the SQL fold says it does" is not only its author's comment — the read-side half is executable and covered. The folds genuinely disagree, which is the paywall-bypass shape: readable by exact path, correctly absent from every listing.
3. The two root causes, both in core
(a)
protectedSegmentswas dead metadata.NodeRepoPackageSource.Peekdropped it — the same defect classpreInstalled,publicSegmentsandcontactEmaileach had (#920) — while the Store'sPluginGateauthored and honoured it in mesh source nodotnet buildorgrep --include='*.cs'over this repo can see. So the partition took the fully-public shape, under which the Store's protection protects nothing on the C# path.(b) 🚨 The boot heal DELETES that protection.
EnsurePartitionPublicRead's legacy heal reads "a policy withholding public read + Public/Anonymous denies on the children" as pre-#902 damage: it retires the denies and rewrites the policy toPublicRead. The live_Submissionsdeny pair matches that fingerprint exactly, andFeedbackis pre-installed, so the heal's restriction does not save it.EnsureDeclaredAccessruns on every install path and as a boot re-assert (InstalledPackageRepairService). The live shape above is one boot repair pass away from being destroyed, logged at Information as a legacy migration. That is what this PR fixes, and nothing in the repo reproduced it before.4. The fix
PackageManifest.ProtectedSegments, read byPeek— core can see the declaration._Policythat withholdsPublicRead. Grants and denies are ROLE rows, which both folds resolve by the same per-subject longest-prefix rule — so the shape cannot answer two ways. Takes precedence over both older branches, pre-installed included. Denies first, policy second, root grants last, so an interrupted run leaves the PUBLIC half dark rather than the segment open._satellite scope is never retired, becauseGatedChildRootsskips every_segment and so no version of this installer could have written one. The partition's own_Accesscontainer is deliberately still in scope — a deny there gates the root, which IS the shape the heal exists to undo.PublicRead, or the legacy fingerprint the heal opens on. My first attempt got this wrong, and the control instance's copy is what showed it: withFeedback/_Policywithholding public read there and no inbox present, the declaration would have added root Public+Anonymous grants and published a partition somebody had closed — a widening introduced by the fix for an exposure, whichmaindoes not do (create-only leaves such a policy alone).ADeclaredProtectedSegment_OnAPartitionThatWithholdsPublicRead_IsGatedButNotOpenedpins the rule.The submit flow is untouched.
Feedback/Feedback/Source/FeedbackSubmitter.cswrites underImpersonateAsSystem(), andSystemshort-circuits the fold — asserted, not assumed (TheGrantShape_LeavesTheReviewerAndSystemReadingTheInbox). Nothing in the policy made the partition writable: the two well-known subjects holdViewer, which is Read only.No ping-pong with the Store.
PluginGatewritesOpenPolicy(publicRead: true) only whenPolicyMatches— i.e. the current policy is a gating one (publicReadoff andredirectOnDenied). The policy this writes carries no redirect, soPolicyMatchesis false andOpenPolicyis never queued. Core now writes and preserves exactly the shape the Store's gate converged on live.5. The control
AnUndeclaredButProtectedSegment_IsNotRetiredAndNotRepublishedByTheHealreproduces the live shape (policy withholdingPublicRead+ the satellite deny pair) with a manifest that declares nothing, plus a deny on an ordinary child as the control on the other side of the split. With the behaviour reverted in place (the satellite split and the declaration routing disabled, field kept so the tests compile), it fails on the assertion that names the delete:and the declaration arm fails on
Expected value to be false … but found True(the policy it must not write). A third control — forcingpublishtrue — fails the create-only arm onClosedPkg/_Access/Public_Access, the grant it must not write. All green with the fix restored. The ordinary-child deny is still retired — without that the narrowing could have been a blanket "never retire anything" wearing the right answer's clothes; it is also what caught my first attempt, where the evidence arm shadowed the heal entirely.6. Verified locally
dotnet build -c Release -warnaserror, one project per invocation, each0 Warning(s) 0 Error(s):MeshWeaver.PluginCatalog,MeshWeaver.Documentation,Memex.Portal.Shared,Memex.Portal.Shared.Test,MeshWeaver.Graph.Test,MeshWeaver.Documentation.Test,MeshWeaver.Layout.Test,MeshWeaver.Compiler.Pipeline.Test(every project referencing the two changed ones).Tests: the whole
Memex.Portal.Shared.Testproject — the suite that ownsEnsureDeclaredAccess— 1761 total, 0 failed;ADeclaredProtectedSegmentSurvivesTheBootHealTest+ProtectedSegmentDiscriminatorTest18/18; the access-control regressionPublicReadPolicyScopeTest+ShippedAccessGrantsTest+AccessAssignmentGuardTest+ the new file 69/69; the doc guardsDocumentationLinkIntegrityTest,DocumentationFrontMatterGuard,DocumentationEmbedIntegrityTest,ArchitectureTopicMapTest17/17.7. What I did NOT establish
PublicRead = true_Policyat a shallow namespace together with a real deeper deny against Postgres: the Plugins tests that cover the shape seed the rows throughaccess_control, which simulates the projection rather than exercising it. That pairing belongs in the repo that has the lane and is named in the doc page as the open gap.public.partition_accessmembership for an anonymous fan-out as aPublicRead-published one. The projection derives those rows fromuser_effective_permissionswherepermission='Read' AND is_allow=true, which the root grants satisfy, and the liveFeedbackpartition has been in the grant shape since 2026-09-20 — but that is a read of the SQL plus a live observation, not a measurement.PluginGate.SeedGating's pre-installed arm, andlastModifiedByissystem-security, but I did not confirm the provenance and did not look for an unrelated live mutation.PostgreSqlSchemaInitializer, the BULK rebuild's policy-cap fold emits deny rows only for subjects already in the shadow table (CROSS JOIN (SELECT DISTINCT user_id FROM user_effective_permissions_shadow)), andPublic/Anonymousare injected later — so in a partition whose only read surface is a_Policy, a deeper_Policy { Read = false }may emit no deny row forPublicat all, while the per-user rebuild function's equivalent fold is unconditional. That contradictsAccessControl.md's own table row. It is a read of the SQL, nothing executed, in another repo — filed rather than folded in here.get/search. The one live WRITE is the bug-triage filing above, through the/feedbackskill on the control instance — a directcreateunderFeedback/_Submissionsthere is refused (Create permission required), which is the submit-as-System design working as intended; whether the agent filed itNewrather thanDraftI had not confirmed at the time of writing.No addresses need recycling for this change: it alters
src/code that runs in the install and boot-repair pipelines, not a NodeType's source or node content a per-node hub binds once. The liveFeedbackpartition needs no write — the fix's purpose is to stop the next boot from changing it.No public surface removed, no interface members added without a default, no i18n keys, no
PackageVersionchange — so noPairs-with:,Implementers:,Mirror-sync:orSatellite-pins:declaration applies.PackageManifest.ProtectedSegmentsis an ADDED member with a default, on a record no sibling repo implements.8. Review round
All eight automatic-review findings answered on their threads; seven accepted and fixed, one declined with reasoning. Three were defects in the change itself and two of those were security-relevant, so they are worth naming here rather than leaving in the thread:
DeclaredAccessMarkerstill named_Policyfor a pre-installed manifest declaring BOTHpublicSegmentsandprotectedSegments— which routes to the scoped shape and writes no policyVerifyDeclaredAccesswould report a CORRECT install as a failure. The marker now mirrors the branch predicate rather than restating it — the rule its own remarks state, which I wrote and then walked intoPublicRead = truepolicy: denies first, then the flip, so a failure in between leaves the blanket grant live and the fresh denies inertCreateOnlydecided by PRESENCE, so aPublic/Anonymousassignment at a declared-protected scope that happened to be a grant survived and the root grant then published the segmentStillNeedsDenypredicate). Narrowing only, and deliberately not applied to the scoped shape's ordinary-child gatingInitialsnapshotInitialframe omitting the satellite deny leaves the protected set empty and the heal proceeds to retire denies and writePublicRead. Filtered forInitial(the idiom at 8 sites here). The legacy retire had been taking a delete decision off the same unfiltered readindex.json→Peek→ manifest hopAProtectedSegmentDeclarationSurvivesDiscoveryTestcovers it through the realListPackageswith an injected fetch; disabling the read fails it, and a manifest without the key still comes back empty_Policyafter it began writing onenew List<MeshNode>()against the collection policyImmutableArrayObservable.Using(ImpersonateAsSystem)toRunAsSystemRetireand the scoped child walk — converting one leaves the belief that the file uses the sealed boundary while two sites do not. A sweep of its own, with its own measurement. I have not reproduced a wrong-flow restore and am not claiming the concern is wrongMeshWeaver Build and Testwas completed/success ona8018c03(the head before this round).9. …and the
Initialfilter needed a second half, from a measurement I did not makeThe review's strongest finding (filter for the
Initialsnapshot) is right about the fail-open, and on its own it would have been worse than the read it replaced. A finding already sitting in the fleet's triage inbox names the reason, with the verbatim log line:That is this query's exact shape and identity — 200+ occurrences in a 400-minute window on
memex, truncated at the log limit, becauseDefersToNativeProvideris false for satellite reads and the pedestrian walk emits nothing until every per-path read completes. So on a Postgres portal theInitialcan genuinely never arrive, and a filter that turned that into "the read says there are no denies" fails open deterministically instead of occasionally.So the listing now answers a tri-state and every arm declines on
Ok: false: nothing retired, and no blanketPublicReadpolicy written over a shape the pass could not see. That restores a rule this method's own remarks had always stated — "healing on an unknown deny set is the one outcome worse than not healing" — which the original-create arm had quietly broken by writing the policy without consulting the listing at all. A host with no query surface is deliberately a different answer (Ok: true, empty): nothing to enumerate is not the same as not knowing.🚨 That one
ifis NOT covered by a test — forcing the listing to fail or to withhold itsInitialneeds a query-provider fault seam this suite has no hook for, so it is reasoned from the production measurement rather than executed. Named here and on the doc page rather than left to look covered.🚨 A CI reading that would have fooled me, recorded because it nearly did: for ~7.5 minutes after the push of
c02d7e68, the only run on that head was thepull_request_targetone (Arm auto-merge) — and thegithub-actionscheck-suite readcompleted/successwithlatest_check_runs_count: 1, while an unrelated PR six minutes earlier already had its full set. That reads exactly like "thepull_requestworkflows never fired", and I had written it up as such. They then arrived and the build wentin_progress. A check-suite'scompleted/successis a snapshot, not a verdict — it closes over whatever runs exist at that moment and reopens when more are created, so it cannot distinguish "nothing was triggered" from "nothing has been triggered YET". The instrument for "did the gate run" is the named workflow's own run, which is what the rule in AGENTS.md already says.10. Coordination with #4969, now that it is in
mainorigin/mainhad moved 9 commits (including #4969). Merged it in.git merge-treereported no textual conflicts — both topic-map entries and bothAccessControl.mdpointers landed side by side with nothing to hand-resolve — and that was the risk, because #4969 left three prose claims the code beneath them no longer matches. A clean merge would have shipped all three:_prefix. The prohibition stands, now cited as the reason for the shape.The two doc pages are cross-linked, and #4969's
PublicReadIsNotSuppressedByADenyTestis expected to keep measuring the divergence — nothing here closes it; this stops depending on it.Re-verified after the merge, each
-c Release -warnaserrorat 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: this change'sAnonymousCannotReadAProtectedSubmissionTest+ #4969'sPublicReadIsNotSuppressedByADenyTest+ the 14-casePublicReadPolicyScopeTesttogether 24/24; the wholeMemex.Portal.Shared.Testproject 1765 total, 0 failed; the four documentation guards 17/17.11. What this does to the LIVE
Feedbackpartition: nothing, on every pathThe claim in §7 that no live write and no recycle are needed, traced against the readings in §1 (
_Policywithholding public read, rootPublic/AnonymousViewer GRANTS from 2026-07-24, the_Submissionsdeny pair, and no legacy deny on any ordinary child):protectedSegments→ declaration armpublishis false (the policy already withholds public read), so only the denies are ensured — and they exist. No write.EnsurePartitionPublicRead, listing readablepublishis false (Legacy.Count == 0, so the legacy fingerprint does not apply). No write.Ok: false— every arm declines. No write.On
maintoday, the second row instead retires both_Submissionsdenies and rewrites the policy toPublicRead = true. That difference is the change, and it is why the fix belongs in core rather than in a live edit.🤖 Generated with Claude Code