Skip to content

fix: exports failed on a test-only API, and a half-written email was lost on connect - #1275

Merged
rbuergi merged 2 commits into
mainfrom
fix/email-draft-persistence-and-export-templates
Aug 12, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/email-draft-persistence-and-export-templates

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Three defects in Share ⇒ as email, all user-visible. Reported from the live portal.


1. 🚨 Sending was broken in production

The user hit this on send:

Script execution failed: (97,48): error CS1061: 'IMeshService' does not contain a definition
for 'QueryAsync' … MeshWeaver.Kernel.Hub.ScriptExecutionException → CompilationErrorException

The three export templates are .csx — compiled at runtime by the kernel, invisible to dotnet build — and they called meshService.QueryAsync<MeshNode>(…), which exists only in MeshWeaver.Fixture, a test-only assembly whose own doc comment reads "Production code MUST NOT use these". Five call sites across ExportPdf.csx, ExportHtml.csx (which the initial report missed) and ExportDocx.csx. Every PDF / DOCX / email export died on the first press of Send.

The CI gap — and why it is now closed at the root

Eleven tests genuinely compile and execute these templates, and all eleven were green. The reason:

KernelScriptReferences built its Roslyn reference set from AppDomain.CurrentDomain.GetAssemblies(). A test process always has MeshWeaver.Fixture loaded (MonolithMeshTestBase : Fixture.TestBase), and its extensions sit in namespace MeshWeaver.Mesh — which the kernel imports for every script. Script compilation in tests was strictly more permissive than in production, so a template could bind an API no deployed portal has and still pass.

The fix is not a new test bolted on top — it is that the reference set now excludes test scaffolding (MeshScriptEnvironment.IsTestScaffolding), making script compilation in a test identical to production. Every one of those eleven tests becomes a real gate. Two new tests pin it directly:

  • ExportTemplates_CompileAgainstTheProductionSurface — compiles all three templates, including branches (deck query, descendants) no runtime test reaches.
  • ScriptReferenceSet_ExcludesTestOnlyAssemblies — asserts the Fixture is absent from the reference set while provably loaded in the process, so it cannot pass by luck.

Call sites move to the reactive Query<T> shape, filtering on the emission shape (ChangeType == Initial), never a count.

AGENTS.md's data-access table and the /code skill both still recommended the dead API to script authors — corrected in the same change.

2. A filled-in form came back empty from consent

"had filled all in, then it asked me to log into ms graph, then it cleared the form."

Connecting Microsoft 365 is a server-side endpoint, so the connect button must navigate with forceLoad: true (#1248) — which tears down the Blazor circuit. The compose state lived in the layout area's /data store under a Guid minted per render. Nothing was persisted, so there was nothing to come back to; the returnUrl returned people to a blank form, which reads as the feature simply losing the work.

The state is now a mesh node, and the form binds directly to it through a $meshNode DataContext. Every control is unchanged — TextField, MeshNodePicker, TextArea, RadioGroup all already route writes through FormComponentBase → BlazorView.UpdatePointer → GetMeshNodeStream(path).Update, debounced, per field. One source of truth; no /data replica and no save subscription (the forbidden replicate-then-save antipattern).

🚨 Where the draft lives — a deliberate deviation from the brief, with evidence

The brief asked for a satellite under the document ({doc}/_Draft/…). That placement cannot be made private, and a draft holds recipient addresses and a personal message. Access is additive along the path (PermissionEvaluator unions every ancestor scope), the stream-cache gate probes the PATH, and the Postgres listing folds visibility on main_node — so every colleague who can read the document would inherit read on everyone's drafts, through four independent read paths. There is no per-node owner flag and no restricting assignment; PartitionAccessPolicy.BreaksInheritance — the closest thing — is not honoured by the SQL fold at all, so search would still leak. ThreadComposerNodeType.PathForNode is the live precedent of exactly this leak today.

So the draft is anchored under its author: {userId}/_Draft/{documentKey} — the framework's actual per-user privacy mechanism, shared with {userId}/_Settings/…, {userId}/_UserActivity/… and {userId}/Feedback/…. Privacy is structural, not asserted. It is still a satellite under a real owner (never top-level) and still names its document, in the path key and in EmailDraft.DocumentPath.

Table: _Draft is deliberately not registered in SatelliteTableMapping.Defaults, so it routes to the partition's ordinary mesh_nodes — exactly as _Settings does. Right answer here: drafts are few, per-user, read only by exact path. A dedicated satellite table would require a migration across every already-provisioned partition (V26_AddNotificationsSatelliteTable shape, since provisioning is promise-cached and never re-run) and buys no query shape.

Lifecycle — created once per (author, document) behind the permission gate, restored on return, reset after 7 days untouched so a stale recipient cannot resurface weeks later, and dropped on successful send and on Cancel (the explicit discard). Abandoning any other way — closing the tab, going through consent — deliberately keeps it.

3. The connect step came last

"see we are logged in before sending."

The mailbox state is probed when the dialog opens, and the connect offer is rendered above the fields. It stays an offer, not a gate: the form is fully usable and sending without connecting still goes from the shared mailbox with a reply-to. The just-in-time prompt survives for consent that lapses mid-compose — belt and braces, and now harmless either way because the draft is already on its node.

Also

  • Recipient suggestions render First Last <a@b.com> — one place, MeshNodePickerView, off the content the query already returns; no contract change. React clients render the picker as a bare input, so nothing to mirror there.
  • Composing now requires a signed-in identity (the draft is filed under the author, and a mail with nobody to reply to should not leave the shared mailbox).

Tests

Test Proves
Draft_SurvivesTheConsentRoundTrip The acceptance test. Fills the draft, discards the circuit, reads back through a fresh handle: recipient, subject, message and delivery all intact. Asserts the path is a function of (author, document) — a per-render id is precisely what could not survive.
ReaderOfTheDocument_CannotReadAnotherUsersDraft Bob is granted Viewer on the document and still has no Read on Alice's draft. Non-vacuous — the first version failed because the base class's blanket admin grant gave Bob All on everything; it now runs with granular permissions.
Author_ReachesTheirOwnDraft_WithoutAnyGrant The negative above isn't passing because the path resolves to nothing.
DraftPath_IsAnchoredUnderTheAuthor_NotTheDocument Pins the placement, since the placement is the privacy mechanism.
Draft_IsDiscardedAfterSending_AndDiscardIsIdempotent No stale draft on the next share.
ExportTemplates_CompileAgainstTheProductionSurface + ScriptReferenceSet_ExcludesTestOnlyAssemblies Item 1, both halves.

Local: 18/18 in MeshWeaver.Hosting.Monolith.Test (including the 7 real export tests, which now execute the templates against a Fixture-free reference set) and 3/3 in MeshWeaver.Security.Test. Touched projects build clean under -c Release -warnaserror. i18n keys added to both server catalogs and the React vendored copies (900 keys each, no missing German).

🤖 Generated with Claude Code

rbuergi and others added 2 commits August 12, 2026 12:02
…lost on connect

Three defects in "Share ⇒ as email", all user-visible.

1) SENDING WAS BROKEN IN PRODUCTION. The three export templates are .csx —
   compiled at RUNTIME by the kernel, invisible to dotnet build — and they called
   meshService.QueryAsync<MeshNode>(...), which exists ONLY in MeshWeaver.Fixture,
   a test-only assembly whose own doc comment says production must not use it. Every
   export threw CompilationErrorException CS1061 the moment a user pressed Send.

   Root cause of the GAP, not just the call: KernelScriptReferences built the Roslyn
   reference set from AppDomain.CurrentDomain.GetAssemblies(), and a test process
   always has MeshWeaver.Fixture loaded (MonolithMeshTestBase : Fixture.TestBase).
   Its extensions sit in namespace MeshWeaver.Mesh, which the kernel imports for every
   script. So script compilation in tests was strictly MORE permissive than in
   production, and eleven tests that genuinely execute these templates all passed.
   The reference set now excludes test scaffolding, so a script compiles in a test
   exactly as it does in production and those eleven tests become real gates. The five
   call sites move to the reactive Query<T> Initial-snapshot shape.

2) A FILLED-IN FORM CAME BACK EMPTY FROM CONSENT. Connecting Microsoft 365 is a
   server-side endpoint, so the connect button must navigate with forceLoad, which
   tears down the Blazor circuit — and the compose state lived in the layout area's
   /data store, keyed by a Guid minted per render. Nothing was persisted, so there was
   nothing to come back to.

   The state is now a mesh node and the form binds directly to it via a $meshNode
   DataContext: TextField, MeshNodePicker, TextArea and RadioGroup are unchanged and
   route their writes through GetMeshNodeStream(path).Update, debounced, per field.
   One source of truth — no /data replica, no save subscription.

   The draft is filed under its AUTHOR ({userId}/_Draft/{documentKey}), not under the
   shared document. A satellite under the document CANNOT be made private: permissions
   are additive along the path and the Postgres listing folds on main_node, so every
   reader of the document would inherit read on everyone's drafts through four
   independent paths, and BreaksInheritance is not honoured by the SQL fold at all.
   A draft holds recipient addresses and a personal message, so it goes where the
   framework already makes per-user state private, alongside {userId}/_Settings and
   {userId}/Feedback. It routes to the ordinary mesh_nodes table, as _Settings does —
   a dedicated satellite table would need a migration across every existing partition
   and buys no query shape.

   Lifecycle: created once per (author, document) behind the permission gate, restored
   on return, reset after 7 days untouched so a stale recipient cannot resurface,
   dropped on successful send and on Cancel (the explicit discard).

3) THE CONNECT STEP CAME LAST. Being sent through consent only after filling the whole
   form is the expensive order. The mailbox state is probed when the dialog opens and
   the connect offer goes above the fields — an offer, not a gate.

Also: recipient suggestions now render "First Last <a@b.com>", so similarly-named
contacts can be told apart and the sender sees where the mail is going. Composing now
requires a signed-in identity. AGENTS.md's data-access table and the /code skill both
still recommended the dead QueryAsync — corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 10:04

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.

Pull request overview

Fixes three user-visible defects in “Share ⇒ as email” by (1) making runtime-compiled export templates compile against the true production API surface, and (2) persisting the compose state as a per-user mesh node so a Microsoft 365 consent round-trip (full page load) can’t wipe the form, plus small UX/i18n improvements around connect/sign-in and recipient disambiguation.

Changes:

  • Replace template usage of test-only QueryAsync with the production reactive IMeshService.Query<T>(...) shape, and harden kernel script reference selection to exclude test-only assemblies.
  • Introduce an EmailDraft node type and rework the send layout area to bind the compose UI directly to the draft node (survives circuit teardown; discard on cancel/send; stale reset).
  • Add tests for draft privacy + survival, and for script-template compilation parity; update i18n catalogs and picker rendering to show emails.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/MeshWeaver.Security.Test/EmailDraftPrivacyTest.cs Verifies drafts are private to the author even when others can read the shared document.
test/MeshWeaver.Hosting.Monolith.Test/SendDocumentDraftTest.cs Acceptance tests for draft persistence across consent teardown and discard semantics.
test/MeshWeaver.Hosting.Monolith.Test/ExportTemplateCompilationTest.cs Compiles templates against production references and asserts test-only assemblies are excluded.
src/MeshWeaver.Messaging.Hub/Localization/strings.en.json Adds new send-dialog strings for connect-first and sign-in messaging (EN).
src/MeshWeaver.Messaging.Hub/Localization/strings.de.json Adds new send-dialog strings for connect-first and sign-in messaging (DE).
src/MeshWeaver.Markdown.Export/Templates/ExportPdf.csx Switches runtime template queries to production Query<T> + “Initial” snapshot filtering.
src/MeshWeaver.Markdown.Export/Templates/ExportHtml.csx Same as above for HTML export template.
src/MeshWeaver.Markdown.Export/Templates/ExportDocx.csx Same as above for DOCX export template.
src/MeshWeaver.Markdown.Export/Layout/SendDocumentLayoutArea.cs Moves compose state to a draft node and updates connect/sign-in flow + discard behavior.
src/MeshWeaver.Markdown.Export/EmailDraft.cs New persisted draft content model for the share-by-email form.
src/MeshWeaver.Markdown.Export/Configuration/MarkdownExportExtensions.cs Registers the new EmailDraft node type as part of Markdown export setup.
src/MeshWeaver.Markdown.Export/Configuration/EmailDraftNodeType.cs New node type + lifecycle helpers (ensure/create/reset/discard) for per-user drafts.
src/MeshWeaver.Kernel.Hub/MeshScriptEnvironment.cs Adds IsTestScaffolding helper used to keep scripts on the production surface.
src/MeshWeaver.Kernel.Hub/KernelScriptReferences.cs Filters Roslyn script references to exclude test scaffolding for prod parity.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-a-half-written-email-is-no-longer-lost-when-you-connect.md What’s New entry describing the user-facing fixes and new guarantees.
src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css Styles the newly displayed email address in picker rows.
src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor Renders email addresses alongside names in the picker dropdown/selection.
content/ai/Skill/code.md Updates script guidance away from QueryAsync and documents the production-surface rule.
clients/react/src/i18n/strings.en.json Mirrors new send-dialog localization keys into the React catalog (EN).
clients/react/src/i18n/strings.de.json Mirrors new send-dialog localization keys into the React catalog (DE).
AGENTS.md Corrects the data-access guidance to reflect the production query surface and snapshot pattern.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

IsSatelliteType = true,
// A half-written mail is not content: it must never surface in search, in the
// create menu, or as agent context.
ExcludeFromContext = new HashSet<string> { "search", "create" },
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

707 tests  ±0   707 ✅ ±0   13m 39s ⏱️ +12s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   7m 58s ⏱️ +3s
2 136 tests +4  2 033 ✅ +4  103 💤 ±0  0 ❌ ±0 
2 375 runs  +4  2 272 ✅ +4  103 💤 ±0  0 ❌ ±0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 330 tests  +28   1 330 ✅ +28   6m 19s ⏱️ -30s
   11 suites ± 0       0 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

This pull request removes 134 and adds 162 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ GetStreamAsync_ExistingFile_ReturnsStream
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ GetStreamAsync_NonExistentFile_ReturnsNull
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ ProviderType_ReturnsFileSystem
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ WriteStreamAsync_CreatesDirectory
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ WriteStreamAsync_CreatesFile
MeshWeaver.Hosting.Monolith.Test.FrameworkStaleProactiveRebuildTest ‑ FrameworkStaleNodeType_ProactivelyRebuilds_WithoutInstanceActivation
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ NodeThatDoesNotExist_StillEmitsNull_Promptly
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ ReadTimesOut_MessageCarriesPendingRequestDiagnostics
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ ReadTimesOut_SurfacesTimeoutException_NotSilentNull
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ ReadTimesOut_WithEmitNullOptIn_StillEmitsNull
…
MeshWeaver.Hosting.Monolith.Test.ExportTemplateCompilationTest ‑ ExportTemplates_CompileAgainstTheProductionSurface(templatePath: \"Templates/Export/Docx\")
MeshWeaver.Hosting.Monolith.Test.ExportTemplateCompilationTest ‑ ExportTemplates_CompileAgainstTheProductionSurface(templatePath: \"Templates/Export/Html\")
MeshWeaver.Hosting.Monolith.Test.ExportTemplateCompilationTest ‑ ExportTemplates_CompileAgainstTheProductionSurface(templatePath: \"Templates/Export/Pdf\")
MeshWeaver.Hosting.Monolith.Test.ExportTemplateCompilationTest ‑ ScriptReferenceSet_ExcludesTestOnlyAssemblies
MeshWeaver.Hosting.Monolith.Test.FrameworkStaleInstanceRenderTest ‑ FrameworkStaleInstance_RendersCompiledArea_NotOverlay
MeshWeaver.Hosting.Monolith.Test.GetDataRequestPropagationTest ‑ LocalUpdate_VisibleViaPolledGetDataRequest
MeshWeaver.Hosting.Monolith.Test.HardDeadlineHonoursFactTimeoutTest ‑ NoTestClass_OverridesHardDeadlineBelowItsOwnDeclaredFactTimeout
MeshWeaver.Hosting.Monolith.Test.HardDeadlineHonoursFactTimeoutTest ‑ TestRunningPastTheDefaultDeadline_IsNotKilled_WhenItDeclaresALargerBudget
MeshWeaver.Hosting.Monolith.Test.KernelReplChainingTest ‑ SecondSubmission_SeesFirstSubmissionsVariablesFunctionsAndTypes
MeshWeaver.Hosting.Monolith.Test.LanguageModelSyncedQueryTest ‑ SyncedQuery_AgentsAndModels_FullyPopulated
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 803 tests  ±0   1 611 ✅ ±0   8m 47s ⏱️ +5s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 313 tests  ±0   1 303 ✅ ±0   13m 17s ⏱️ -1s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 116 tests   - 22   2 115 ✅  - 22   7m 9s ⏱️ - 1m 12s
   11 suites ± 0       1 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

This pull request removes 156 and adds 134 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.FrameworkStaleInstanceRenderTest ‑ FrameworkStaleInstance_RendersCompiledArea_NotOverlay
MeshWeaver.Hosting.Monolith.Test.GetDataRequestPropagationTest ‑ LocalUpdate_VisibleViaPolledGetDataRequest
MeshWeaver.Hosting.Monolith.Test.HardDeadlineHonoursFactTimeoutTest ‑ NoTestClass_OverridesHardDeadlineBelowItsOwnDeclaredFactTimeout
MeshWeaver.Hosting.Monolith.Test.HardDeadlineHonoursFactTimeoutTest ‑ TestRunningPastTheDefaultDeadline_IsNotKilled_WhenItDeclaresALargerBudget
MeshWeaver.Hosting.Monolith.Test.KernelReplChainingTest ‑ SecondSubmission_SeesFirstSubmissionsVariablesFunctionsAndTypes
MeshWeaver.Hosting.Monolith.Test.LanguageModelSyncedQueryTest ‑ SyncedQuery_AgentsAndModels_FullyPopulated
MeshWeaver.Hosting.Monolith.Test.LanguageModelSyncedQueryTest ‑ SyncedQuery_GetQueryById_ReusesSameUpstreamAcrossCalls
MeshWeaver.Hosting.Monolith.Test.LayoutAreaResubscribeHostAccumulationTest ‑ RepeatedWrites_DoNotStackLayoutAreaHosts_OnTheOwner
MeshWeaver.Hosting.Monolith.Test.LinkedInProfileLayoutAreaTest ‑ LinkedInProfile_NodeType_CompilesAndRendersOverview
MeshWeaver.Hosting.Monolith.Test.LinkedInPublishServiceTest ‑ PublishPost_accepts_a_node_native_post_that_names_its_profile_as_authorPath
…
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ GetStreamAsync_ExistingFile_ReturnsStream
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ GetStreamAsync_NonExistentFile_ReturnsNull
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ ProviderType_ReturnsFileSystem
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ WriteStreamAsync_CreatesDirectory
MeshWeaver.Hosting.Monolith.Test.FileSystemStreamProviderTests ‑ WriteStreamAsync_CreatesFile
MeshWeaver.Hosting.Monolith.Test.FrameworkStaleProactiveRebuildTest ‑ FrameworkStaleNodeType_ProactivelyRebuilds_WithoutInstanceActivation
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ NodeThatDoesNotExist_StillEmitsNull_Promptly
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ ReadTimesOut_MessageCarriesPendingRequestDiagnostics
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ ReadTimesOut_SurfacesTimeoutException_NotSilentNull
MeshWeaver.Hosting.Monolith.Test.GetMeshNodeTimeoutSurfacingTest ‑ ReadTimesOut_WithEmitNullOptIn_StillEmitsNull
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ± 0     65 suites  ±0   57m 12s ⏱️ - 1m 23s
9 405 tests +10  9 099 ✅ +10  306 💤 ±0  0 ❌ ±0 
9 644 runs  +10  9 338 ✅ +10  306 💤 ±0  0 ❌ ±0 

Results for commit 2ce2764. ± Comparison against base commit e4de912.

@rbuergi
rbuergi merged commit 3c91fe4 into main Aug 12, 2026
28 checks passed
rbuergi added a commit that referenced this pull request Aug 12, 2026
… is gating

ExportTemplateCompilationTest (added by #1275) went red on this branch for all
three templates. It is NOT an access-control failure and nothing in this PR's
diff can reach it — three independent proofs:

* the test uses MonolithMeshTestBase's DEFAULT ConfigureMesh, which routes
  through ConfigureMeshBase → AddRowLevelSecurity(), so an evaluator IS
  registered and the new fail-closed branch cannot fire;
* the sibling test in the SAME class on the SAME mesh passed (1 passed / 3
  failed), so the mesh built and the MapMeshWeaver boot gate never ran; and
* the diagnostics are CS0234 "the type or namespace name 'Export' does not
  exist in the namespace 'MeshWeaver.Markdown'" plus a cascade of CS0246 for
  DocumentBuilder / ExportFormat / RenderedDocument / BrandingResolver /
  DocxDocumentRenderer — a missing ASSEMBLY REFERENCE, not a permission.

ROOT CAUSE. The script reference set is the process-global Lazy snapshot of
AppDomain assemblies UNION the per-session KernelScriptAssembly contributions
(MeshScriptEnvironment.SessionAssemblies). MeshWeaver.Markdown.Export is in
neither the curated core set nor, for this fixture, the contributions —
AddMarkdownExport() is what registers it, and that registration says exactly
why in as many words: "Without this the export template .csx files can't
resolve using MeshWeaver.Markdown.Export.* when AppDomain hasn't eagerly
loaded the assembly before the first script run."

A default MonolithMeshTestBase mesh never calls AddMarkdownExport, so the
fixture was not reproducing production — it was reproducing a portal that does
not have the export feature installed, and the templates failed to resolve
their OWN namespace.

WHY IT EVER PASSED, AND WHY IT SURFACED HERE. The snapshot is a process-global
Lazy, so the assembly was in the set iff something else in the same test
process had already loaded it when the snapshot was first materialized —
green or red by which class ran first in the shard. That is a latent
order-dependency in the new test, not a property of any branch; this branch
adds test classes to two other projects, which is enough to change what runs
first. Registering the module removes the luck: SessionAssemblies is
recomputed per call and never memoized.

Proven, not assumed — same filter, same process, only the mesh config differs:
  without AddMarkdownExport → Failed: 3, Passed: 1
  with    AddMarkdownExport → Failed: 0, Passed: 4

Also: the assertion reported only a COUNT ("found 40 item(s)") and never the
diagnostics, so the one thing the failure exists to communicate — which API a
runtime-compiled script cannot resolve — could not be read from CI at all. The
message now names them.

No gate was softened, no permission special-cased, and the export path is not
special-cased: the fixture is given the real production configuration for the
feature under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rbuergi
rbuergi deleted the fix/email-draft-persistence-and-export-templates branch August 19, 2026 10:30
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