Skip to content

fix(#3542): a bookkeeping write that cannot read the policy record refuses it - #3619

Merged
meshweaver-cloud[bot] merged 1 commit into
mainfrom
fix/3542-unreadable-policy-record-refused
Sep 7, 2026
Merged

meshweaver-cloud[bot] merged 1 commit into
mainfrom
fix/3542-unreadable-policy-record-refused

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Refs #3542 — proposal 3's first part. (The second, "an absent policy must read as None", 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/main at 5453be493 (the merge commit of #3607) before any change:

Seed Admin/UpdatePolicy with 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:

MeshNodeStreamCache.GetStream: Content for Admin/UpdatePolicy stayed an untyped JsonElement
after deserialization — downstream 'Content is X'/'as X' consumers will fail

Then one RecordAvailable-shaped bookkeeping write, which completed silently, leaving:

{"requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.9999","checkedAt":"…","comboVerifications":[]}

policy is gone — the production state #3542 opens on ("a policy record that lost its own policy"), reproduced from a single routine check. Along with it went latestAvailableTag, and in the general case every ComboVerification and any live HeldTag/HeldReason.

Why, and why it is not the field's location

All four writes on the node — RecordCheck, RecordAvailable, RecordHold (SelfUpdateHostedService) and RecordVerification (UpdatePolicyNodeType) — read through UpdatePolicyNodeType.ParseContent, which answers new 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 leave RecordAvailable, RecordHold and RecordVerification erasing 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:

🚨 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.

All four now use it. Unreadable content faults with a MeshNodeStreamException naming the path, the runtime type and a JSON excerpt — the write does not happen — and every caller already wraps its bookkeeping write in a .Catch that logs and carries on (#1020: a bookkeeping write may never gate the roll). Refused loudly, record left intact:

MeshNode Deserialization at 'Admin/UpdatePolicy': Update<UpdatePolicyContent> refused to write:
content is untyped JSON (JsonElement) and could not be read as 'UpdatePolicyContent'.
The write was NOT applied — writing a default-valued record here would persist those defaults
over every field the caller never touched.

Also in this change

  • 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 "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 "None is now the enum's zero value". It is not — Continuous still is, deliberately, because reordering would make an explicit None unwritable under DefaultIgnoreCondition = WhenWritingDefault. Corrected, and the duplicate <summary> block left on DeclaredPolicy is removed.
  • Work product conserved: 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's Status reset, and this one).

Verification

UnreadablePolicyRecordIsNotClobberedTest drives all four production writes against a real mesh — RecordVerification through its public API, the other three through a derived class exposing the poller's own protected virtual members. 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 src files 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.Shared and Memex.Portal.Shared.Test, both 0 Warning(s) 0 Error(s). DocumentationLinkIntegrityTest + WhatsNewEntryIntegrityTest green 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 write events, which have no fix on main and are a separate root.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings September 7, 2026 15:27
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 7, 2026 15:31

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.

🟢 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/UpdatePolicy bookkeeping writes (RecordCheck, RecordAvailable, RecordHold, RecordVerification) to the typed GetMeshNodeStream(path).Update<TContent>((node, cur) => …) overload so unreadable content faults and the write is not applied.
  • Reworks UpdatePolicyNodeType.ParseContent to use ObjectAsExtensions.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.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 642 tests  ±0   1 448 ✅ ±0   2m 11s ⏱️ +11s
    4 suites ±0     194 💤 ±0 
    4 files   ±0       0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

251 tests  ±0   251 ✅ ±0   2m 18s ⏱️ -6s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ±0      5 suites  ±0   2m 30s ⏱️ +10s
1 586 tests +8  1 586 ✅ +8  0 💤 ±0  0 ❌ ±0 
1 587 runs  +8  1 587 ✅ +8  0 💤 ±0  0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 144 tests  ±0   1 144 ✅ ±0   4m 2s ⏱️ -9s
    3 suites ±0       0 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

492 tests  ±0   492 ✅ ±0   46s ⏱️ +3s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 051 tests  ±0   1 051 ✅ ±0   1m 19s ⏱️ -1s
    2 suites ±0       0 💤 ±0 
    2 files   ±0       0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Test Results

   16 files  ±0     16 suites  ±0   13m 9s ⏱️ +11s
6 166 tests +8  5 972 ✅ +8  194 💤 ±0  0 ❌ ±0 
6 167 runs  +8  5 973 ✅ +8  194 💤 ±0  0 ❌ ±0 

Results for commit dbd30a4. ± Comparison against base commit 0585b87.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit da6f49a Sep 7, 2026
30 checks passed
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>
@rbuergi
rbuergi deleted the fix/3542-unreadable-policy-record-refused branch October 10, 2026 13:10
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