Repository navigation
fix(#3542): a bookkeeping write that cannot read the policy record refuses it - #3619
Merged
meshweaver-cloud[bot] merged 1 commit intoSep 7, 2026
Merged
Conversation
…fuses it Proposal 3's remaining half. #3607 made an ABSENT `policy` field read as `None` rather than as "enabled" — that fixed what an absent field MEANS, but not the write that makes it absent. All four writes on `Admin/UpdatePolicy` — RecordCheck, RecordAvailable, RecordHold and RecordVerification — read the current content through `UpdatePolicyNodeType.ParseContent`, which answers `new UpdatePolicyContent()` for BOTH "there is no content" and "the content is present and this build cannot read it", and then persisted that empty record. A failed READ became a write that erased the admin's policy, the latest available tag, every combo verdict and any live availability hold. MEASURED on origin/main at 5453be4 (i.e. WITH #3607): seed `{"policy":"None","latestAvailableTag":"3.0.0-ci.8009","comboVerifications":"corrupt"}` — one field this build cannot deserialize, so the record reaches the write lambda as an untyped JsonElement — then one RecordAvailable-shaped write completed SILENTLY and left `{"requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.9999","comboVerifications":[]}`. No `policy` at all: exactly the production state #3542 opens on, from one bookkeeping write. The fix invents nothing. The framework already has the primitive built for this class — `Update<TContent>((node, content) => …)`, whose own docs say "null means ABSENT, never 'could not be read'… Conflating the two is what turns 'the content did not deserialise' into 'the content was empty, write a fresh one' — the write that destroys the real record." The four sites were simply never migrated to it. They are now: unreadable content faults with a MeshNodeStreamException carrying the path, the runtime type and a JSON excerpt, the write does not happen, and every caller's existing `.Catch` logs it and carries on (#1020: a bookkeeping write may never gate the roll). Moving `lastCheckedAt` to a separate node — the other repair the issue proposes — would NOT have closed this: the three sibling writes have the same shape on the same record, so the defect is read-failure-becomes-a-default, not the co-location. Also here: - ParseContent is reimplemented on ObjectAsExtensions.As<T>, so a READ additionally covers the as-written JsonNode DOM and a same-named record from another collectible assembly — both of which the hand-rolled JsonElement switch answered with a silent default — and it now documents that it must never feed a write. - UpdatePolicyMcpPatchTest's two production-shape mirrors move to the typed write, so the mirror keeps testing the shape it claims to. - AbsentUpdatePolicyFailsClosedTest's prose said "None is now the enum's zero value"; it is not, deliberately (reordering would make an explicit None unwritable under DefaultIgnoreCondition = WhenWritingDefault). Corrected, and the duplicate <summary> block #3607 left on DeclaredPolicy is removed. - Doc/Architecture/CqrsAndContentAccess now teaches the typed write as THE write shape, with the read/write asymmetry stated: a read may tolerate bad data, a write may not, and building one on the other is what destroys the record. Verification: UnreadablePolicyRecordIsNotClobberedTest drives all four PRODUCTION writes (RecordVerification is the public API; the other three are the poller's own protected virtuals reached through a derived class) against a real mesh. Falsified by reverting only the src change and re-running: exactly the 4 refusal arms fail and the 4 positive controls pass; with the fix, 8/8. 93/93 across every self-update suite in the project. `dotnet build -c Release -warnaserror` clean, 0 warnings, on Memex.Portal.Shared and Memex.Portal.Shared.Test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The change consistently applies the typed-update mechanism across all relevant writers, preserves the “bookkeeping must not gate” behavior via existing .Catch handling, and is backed by a targeted regression test plus positive controls.
Pull request overview
This PR addresses #3542’s remaining “policy clobber” failure mode by ensuring bookkeeping writes to Admin/UpdatePolicy refuse unreadable existing content instead of defaulting it and persisting those defaults back onto the node (which previously erased fields like policy, tags, holds, and combo-verification history).
Changes:
- Migrates all
Admin/UpdatePolicybookkeeping writes (RecordCheck,RecordAvailable,RecordHold,RecordVerification) to the typedGetMeshNodeStream(path).Update<TContent>((node, cur) => …)overload so unreadable content faults and the write is not applied. - Reworks
UpdatePolicyNodeType.ParseContentto useObjectAsExtensions.As<T>(broader read tolerance: typed instance,JsonElement,JsonNode, and foreign-assembly same-named types), while documenting it as read-only (must not feed writes). - Adds a regression test that drives all four production write paths against an unreadable record and proves it is refused (and also adds positive controls), plus updates docs/What’s New to teach the typed-write shape and the read/write asymmetry.
File summaries
| File | Description |
|---|---|
| test/Memex.Portal.Shared.Test/UpdatePolicyMcpPatchTest.cs | Updates test helpers to mirror the production typed-write shape for Admin/UpdatePolicy mutations. |
| test/Memex.Portal.Shared.Test/UnreadablePolicyRecordIsNotClobberedTest.cs | New regression test proving unreadable policy content is refused (not clobbered) across all four production write paths, with positive controls. |
| test/Memex.Portal.Shared.Test/AbsentUpdatePolicyFailsClosedTest.cs | Corrects explanatory prose about enum zero-value and ties the “absent means None” behavior to the new clobber-prevention coverage. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-07-your-update-settings-survive-the-portals-own-bookkeeping.md | Adds user-facing release note describing the fixed failure mode (unreadable record no longer overwritten). |
| src/MeshWeaver.Documentation/Data/Architecture/CqrsAndContentAccess.md | Updates guidance to make Update<TContent> the primary write pattern and explicitly warns against building writes on bad-data-tolerant reads. |
| memex/Memex.Portal.Shared/SelfUpdate/UpdatePolicyNodeType.cs | Switches RecordVerification to typed update; reimplements ParseContent via ObjectAsExtensions.As<T> and documents why it must not drive writes. |
| memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs | Switches RecordCheck, RecordAvailable, and RecordHold to typed updates; existing call sites continue to treat these as best-effort bookkeeping via .Catch(...). |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This was referenced Sep 7, 2026
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
This was referenced Sep 7, 2026
Merged
rbuergi
added a commit
that referenced
this pull request
Sep 7, 2026
…e that models production Measured, not predicted: with the gate able to fire, #3619's UnreadablePolicyRecordIsNotClobbered put EIGHT MeshNodeContentDegradedException records into the trace log and would have red its shard on every run — a TRUE positive, because its seeded JSON carries no resolvable $type and is therefore content nothing can read, which is precisely what the gate reports. An allow-list was refused: it would be a permanently green check wearing a reason. The fixture is what changes, and the change makes it stronger. The record now names a REGISTERED foreign type (ForeignPolicyRecord — the same fields, with comboVerifications as a string where this build declares a list), so the stream cache materialises it and nothing degrades, while As<UpdatePolicyContent> still answers null because it recovers a foreign runtime type only when the short name matches. That is the production case the fixture's own comment already claimed to model — "a field written by a build whose content type differed" — and it was not modelling it. Falsified both directions, against the SAME fixture: reverting #3619's two src files fails exactly the 4 refusal arms with the 4 positive controls passing (#3619's own reported result, unchanged); with the fix, 8/8, and the marker count in the trace goes 8 -> 0 with the gate exiting 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rbuergi
added a commit
that referenced
this pull request
Sep 7, 2026
…— no allow-list, nothing skipped
The repaired gate red shard 4 on LateContentTypeRegistrationTest. Measured on run 34150643931, and
the denominator is smaller than it looked: the Occurrences list is exactly lcu…/Live, lcr…/Live,
lcn…/Live — that one class's three tests. The OverviewUnresolvedContentTypeTest warnings in the same
shard come from ObjectPolymorphicConverter, carry no marker and say "can only be read as", so they
never matched the gate at all.
Neither answer the gate offers applies here.
* An ALLOW-LIST is refused on principle — and these paths carry a per-run GUID, so the entry
would have to be a PATTERN: an exemption that widens by itself.
* The prescribed FIXTURE CHANGE (seed a DIFFERENT, REGISTERED type — f4b0420's repair of
#3619's fixture) deletes the subject here. #2952 is about a NodeType compiled at RUNTIME into a
collectible assembly, so "registered nowhere yet" IS the premise, and
AnUnrelatedRegistration_LeavesGenuinelyUnresolvableContentUntyped asserts that content nothing
can resolve STAYS untyped.
So the third answer, and it is the pattern this PR already set one test over.
UntypedContentDegradationReachesTheTraceSinkTest drives the production emitter against its OWN
recording logger. The emitter takes its logger from DI, so the same move works for a whole mesh:
LateContentTypeRegistrationTest substitutes the CLOSED ILogger<MeshNodeStreamCache> for its own mesh
with a decorator that takes MeshNodeContentDegradedException records and forwards everything else
untouched.
🚨 Why that is not the skip-trapdoor an allow-list would be — four properties, all four needed:
1. Nothing is silenced. No level is dialled, no category is muted; every other record the cache
logs reaches the real logger at its own level.
2. THE DIVERSION IS AN ASSERTION. Each test asserts the record WAS produced, that it names the
expected node, and that it satisfies the trace sink's OWN predicate
(exception is not null && level >= Warning). A declaration that matches nothing is a RED —
the one property an allow-list can never have, and strictly more than these tests asserted
before, when the record went to a file nobody read.
3. The exemption is exactly ONE NODE wide: the same assertion pins that everything captured names
the declared path, so an unintended degradation elsewhere in that mesh fails the test rather
than being swallowed.
4. UntypedContentDegradationGate.ADivertedDegradationIsAssertedWhereItIsDiverted is the control
arm, with a denominator: it reds if any test diverts without asserting, AND if it ever
examines zero files.
FALSIFIED, not assumed, in both directions:
* exception argument reverted at the GetStream seam (the #3625 defect itself) -> all 3 tests FAIL
on "the platform must REPORT the degradation this test reproduces". The regression is now
caught by a TEST, not only by a gate that must first see a whole shard's logs.
* with the fix -> 3/3 pass, marker count in the shared trace 3 -> 0,
check-untyped-content.sh exit 1 -> exit 0.
* the new guard's marker mutated -> it reds naming LateContentTypeRegistrationTest.cs, so it is
reading the production tree and the check bites.
* Release + -warnaserror, one project per invocation: MeshWeaver.Documentation,
MeshWeaver.Documentation.Test, MeshWeaver.Graph.Test, MeshWeaver.Hosting.Test — 0 Warning(s),
0 Error(s) each.
🚨 NOT fixed here, filed as #3645: the gate cannot tell a TRANSIENT degradation from a FINAL one.
MeshNodeStreamCache warns at the instant of a read; MeshNodeTypeSource logs the same event naming
both causes ("(a) … YET, which is TRANSIENT … (b) no declaration will ever claim this") and carries
no exception, so it reds nothing. One event, two descriptions, opposite CI consequences — and two of
these three tests degrade AND RECOVER inside one test. Telling them apart needs a window nothing in
a process naturally closes: a rework, not this PR's red.
Review (Copilot x4), all four addressed:
* check-untyped-content.sh now excludes the binary artefacts by NAME on BOTH greps
(--exclude='*.dmp' '*.dll' '*.pdb'; collected-logs/ carries mini dumps and a symbols-<proj>/
of dll+pdb). Deliberately NOT `-I`: that decides by CONTENT, so one stray NUL in a text log
makes it invisible and the gate reports "no degradation" — a pass-by-scanning-nothing, which
is the outcome this script exists to make impossible. A false red is loud; a false green is not.
* the seam count is a regex over comment/string-MASKED source, so a prose mention of the type in
that heavily-commented file cannot be counted and a wrapped or alias-qualified construction
cannot be missed.
* the reachability control asserts over the SINK-PREDICATE population rather than every record;
RecordingLogger.IsEnabled is Warning+ and Log honours it. (Narrowing IsEnabled alone would have
been a no-op — LoggerExtensions call ILogger.Log unconditionally.)
Doc/Architecture/ReadingCiSignals now answers the open policy question it had left standing, and the
What's New entry carries the user-facing half plus the transient caveat.
Refs #3625, #3645
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCM41UknXQwgXqH39tLo9u
rbuergi
added a commit
that referenced
this pull request
Sep 9, 2026
…usals were the rate Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #3542 — proposal 3's first part. (The second, "an absent
policymust read asNone", landed as #3607; nothing here reopens it, and the enum is deliberately not reordered.)The residue, measured — #3607 did not close it
The brief asked me to check whether #3607 had already closed the clobber. It has not, and here is the measurement, taken on
origin/mainat5453be493(the merge commit of #3607) before any change:Seed
Admin/UpdatePolicywith a real record carrying one field this build cannot deserialize:{"policy":"None","requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.8009","comboVerifications":"written-by-a-shape-this-build-cannot-read"}The stream cache says what happens next, in a Warning nobody was reading:
Then one
RecordAvailable-shaped bookkeeping write, which completed silently, leaving:{"requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.9999","checkedAt":"…","comboVerifications":[]}policyis gone — the production state #3542 opens on ("a policy record that lost its own policy"), reproduced from a single routine check. Along with it wentlatestAvailableTag, and in the general case everyComboVerificationand any liveHeldTag/HeldReason.Why, and why it is not the field's location
All four writes on the node —
RecordCheck,RecordAvailable,RecordHold(SelfUpdateHostedService) andRecordVerification(UpdatePolicyNodeType) — read throughUpdatePolicyNodeType.ParseContent, which answersnew UpdatePolicyContent()for both "there is no content" and "the content is present and this build cannot read it". Those are different facts. The write then persists that empty record.So I did not move
lastCheckedAt, which is the issue's other suggestion, and the evidence is what decided it: the three sibling writes have the same shape on the same record, so moving one field would leaveRecordAvailable,RecordHoldandRecordVerificationerasing it exactly as before. The defect is a failed read becoming a default that gets written, not the co-location.The fix invents nothing
The framework already has the primitive built for this exact class, and these four sites had simply never been migrated to it. From
MeshNodeStreamHandle.Update<TContent>'s own docs:All four now use it. Unreadable content faults with a
MeshNodeStreamExceptionnaming the path, the runtime type and a JSON excerpt — the write does not happen — and every caller already wraps its bookkeeping write in a.Catchthat logs and carries on (#1020: a bookkeeping write may never gate the roll). Refused loudly, record left intact:Also in this change
ParseContentis reimplemented onObjectAsExtensions.As<T>, so a read additionally covers the as-writtenJsonNodeDOM and a same-named record from another collectible assembly — both of which the hand-rolledJsonElementswitch answered with a silent default — and it now documents that it must never feed a write.UpdatePolicyMcpPatchTest's two "mirrors the production shape exactly" helpers move to the typed write, so the mirror keeps testing the shape it claims to.AbsentUpdatePolicyFailsClosedTest's prose asserted "Noneis now the enum's zero value". It is not —Continuousstill is, deliberately, because reordering would make an explicitNoneunwritable underDefaultIgnoreCondition = WhenWritingDefault. Corrected, and the duplicate<summary>block left onDeclaredPolicyis removed.Doc/Architecture/CqrsAndContentAccess→ "Writes" now teaches the typed overload as the write shape and states the asymmetry plainly — a read may tolerate bad data, a write may not, and building one on the other is what destroys the record. It cites both production instances (AppendUserInput'sStatusreset, and this one).Verification
UnreadablePolicyRecordIsNotClobberedTestdrives all four production writes against a real mesh —RecordVerificationthrough its public API, the other three through a derived class exposing the poller's ownprotected virtualmembers. Each has a positive control on a readable record, because "the write was refused" would otherwise be satisfied by a write that never works.Falsified. Reverting only the two
srcfiles and re-running: exactly the 4 refusal arms fail, the 4 positive controls pass. With the fix: 8/8. Then 93/93 across every self-update suite in the project (ComboGateRollTest,ComboVerdictRecordingTest,SelfUpdateStrandRecoveryTest,UpdatePolicyMcpPatchTest,NonAdminUpdateStatusTest,PlatformUpdateStatusTest,VersionSelectTest,AbsentUpdatePolicyFailsClosedTest).One thing worth recording, because it wasted a cycle: the three poller arms first failed their positive control with a one-shot read of the node — the write had landed and the read answered with the pre-write snapshot. Fixed by waiting on the condition (
.Where(settled).FirstAsync().Timeout(…)), which is the house rule; the refusal arms keep the single read deliberately, and say why in a comment — a refusal happens inside the update lambda, so no patch is posted and there is no later emission to wait for.dotnet build -c Release -warnaserror, one project per invocation:Memex.Portal.SharedandMemex.Portal.Shared.Test, both0 Warning(s) 0 Error(s).DocumentationLinkIntegrityTest+WhatsNewEntryIntegrityTestgreen after the doc edits.What's New:
Category: Fix— "Your update settings survive the portal's own bookkeeping".#3542 stays open after this: proposal 3 is then complete, but the issue also still carries the ~1300
[MergeGuard] Admin/UpdatePolicy: refused stale/reordered cross-hub writeevents, which have no fix onmainand are a separate root.🤖 Generated with Claude Code