Skip to content

Module closure: should a sibling that is ITSELF another package's declared module ride into a second bundle? #3221

Closed

Description

@rbuergi

Raised from Systemorph/MeshWeaver.Plugins#1271, which closes as a pointer to this one — the decision belongs to the core pack lane (module-owned-platform.sh / ModuleClosureAccounting), not to a satellite.

The gap

BakeHost.ShippedByHostProblem (#3175) refuses a module that is composed with --module AND shipped by the platform host — it checks the host's /app directory and its surface manifest. It does not check whether a second module bundle carries a copy of the same assembly name.

That case is live. Since Plugins#1268 removed MeshWeaver.Markdown.Collaboration.dll from the portal image's app closure, the AI bundle rides a copy of it as a module-owned sibling (ModuleClosureAccounting: a module-owned MeshWeaver.* sibling rides). So the assembly has two module-side producers:

  • Essentials declares MeshWeaver.Markdown.Collaboration as its module (Essentials/index.json);
  • AI carries a copy of the same DLL as a riding sibling.

The Chat bundle already has this exact shape with MeshWeaver.AI.dll today, so this is a pre-existing accepted pattern, not a regression from #1268 — nothing is known to be broken by it right now.

Why it is worth deciding rather than leaving implicit

The failure mode #3175 exists to prevent is a dependency-record mismatch at adoption: records naming one build (mvid:…) while the consumer resolves another (ref:…, or a different mvid:), which DECLINES every NodeType binding the name. The guard closes the host-vs-module case. The module-vs-module case is closed only by the accident that both copies happen to be built from the same source in the same wave — a property of the current CD wave, not an invariant anyone asserts.

So the question is not "is it broken today" (it is not) but "what closes it when the wave stops being uniform" — which is exactly the shape of failure #3175 was written for.

The decision

Should a sibling that is itself another package's declared module ride into a second bundle, or should the closure rule exclude declared modules and let the landing resolve them from their owning package?

If the answer is "it should not ride", the guard extension is the same shape as #3175: refuse a bundle that carries an assembly another package declares as its module. That is a guard which can refuse an existing caller (Chat/MeshWeaver.AI today), so by the working agreement it lands with its caller sweep or in grace mode — never on its own.

Surfaced while landing Plugins#1268 (Closes Plugins#1262). No action taken there.

Activity

  1. rbuergi commented on Sep 8, 2026

    @rbuergi
    ContributorAuthor

    The prediction in this issue came true, and it is now blocking production

    Reopening: #3242 closed this by making the verdict count every copy — it did not decide whether
    the copy should ride. That detector is now firing on memex, and it is why nothing rolls.

    Measured 2026-09-08, Admin/UpdatePolicy on memex (memex.systemorph.com), holding
    3.0.0-ci.8053:

    Collaboration: 'Collaboration' (Collaboration/Review) binds module MeshWeaver.Markdown.Collaboration,
    which the set sealed for framework identity sa927163874eb5a136826a1cf8cd91ffb carries as TWO DIFFERENT
    builds (module MeshWeaver.Markdown.Collaboration: source 'plugins' sealed
    mvid:0b7040585b9e4c22b1457de2b6e86189 as a sibling riding 'ai.module.nupkg', source 'plugins' sealed
    mvid:6fe8369aa39c4ce0b3203c0e304fe5bc as the declared module of 'essentials.module.nupkg') — the
    loader keeps whichever it sees first, so the other's NodeTypes are declined at adoption;
    the set is inconsistent and nothing rolls
    

    Two different MVIDs, exactly as written here:

    "What closes the module-vs-module case today is the accident that both copies happen to be
    built from the same source in the same wave — a property of the current CD wave, not an invariant
    anyone asserts. The failure #3175 exists to prevent … is exactly what a non-uniform wave would
    produce."

    The wave stopped being uniform. The detector is behaving correctly; the cause is untouched.

    Why this is not just one more held candidate

    It is set-level. The other two reasons in the same hold are per-module and clear on their own —
    the rc-line floors (core #3554 + Plugins #1447, merged, awaiting a sealed set) and five bundles with
    no sealed bake. This one refuses the whole set regardless: fixing the floors and the bakes still
    leaves nothing rolls. So it gates every roll of memex, including the roll that would deliver any
    other fix.

    The decision this issue posed, now forced

    Should a sibling that is itself another package's declared module ride into a second bundle, or
    should the closure rule exclude declared modules and let the landing resolve them from their owning
    package?

    The concrete instance: src/MeshWeaver.AI/MeshWeaver.AI.csproj carries

    <!-- CollaborationPlugin posts CreateCommentRequest / CreateCommentResponse, which live in
         the collaboration module. The reference used to resolve against the platform's copy of
         this assembly; the assembly moved repos but deliberately KEPT ITS NAME … (#2370). -->
    <ProjectReference Include="../MeshWeaver.Markdown.Collaboration/MeshWeaver.Markdown.Collaboration.csproj" />

    so ModuleClosureAccounting rides a copy into ai.module.nupkg, while Essentials/index.json
    declares the same assembly as its module.

    Three shapes, in increasing order of blast radius:

    1. Exclude declared modules from the closure (this issue's own suggestion) — the sibling stops
      riding and the landing resolves it from Essentials. Fixes the class. Needs the caller sweep this
      issue already flags (Chat rides MeshWeaver.AI.dll the same way), so grace mode.
    2. Split the contract. CreateCommentRequest / CreateCommentResponse move to a
      platform-shipped contract assembly; MeshWeaver.AI references that and rides nothing — the
      MeshWeaver.Speech.Contract / MeshWeaver.Observability.Contract precedent. Durable, but it
      moves public message types across a repo boundary, so it meets the pair and binary-compat gates.
    3. Make the wave uniform again — republish so both copies share an MVID. Unblocks today and
      restores the accident this issue exists to stop relying on.

    3 is the stopgap; 1 or 2 is the fix. I have not taken any of them — the choice is a pack-lane
    decision and it can refuse existing callers, which by the working agreement lands with a sweep.

    Found while diagnosing a separate production symptom (a node opening on the wrong default area,
    fixed in #3694); this one is unowned and is the roll blocker.

  2. reopened this on Sep 8, 2026
  3. added a commit that references this issue on Sep 8, 2026
  4. rbuergi commented on Sep 9, 2026

    @rbuergi
    ContributorAuthor

    The question is now answered empirically — by the exact pair this issue names

    This issue asks whether a sibling that is itself another package's declared module should ride
    into a second bundle, and names the live instance: Essentials declares
    MeshWeaver.Markdown.Collaboration as its module, while AI carries a copy as a riding sibling.

    Production has answered it. Read live from Admin/UpdatePolicy on memex
    (lastCheckedAt: 2026-09-09T07:39:42Z), inside heldReason — the same two producers, by name:

    Collaboration: 'Collaboration' (Collaboration/Review) binds module
    MeshWeaver.Markdown.Collaboration, which the set sealed for framework identity
    sf456af88e1d9c07c0d9d75de97b0800c carries as TWO DIFFERENT builds
      (source 'plugins' sealed mvid:0ab3db43f03745e49d2145e958b673c0
          as a sibling riding 'ai.module.nupkg',
       source 'plugins' sealed mvid:a1e559cf1dbc49dab6184554d4aef717
          as the declared module of 'essentials.module.nupkg')
    — the loader keeps whichever it sees first, so the other's NodeTypes are declined at
    adoption; the set is inconsistent and nothing rolls
    

    So the answer to the title question is no, and the reason is not aesthetic:

    1. The loader cannot choose. "keeps whichever it sees first" — the outcome depends on scan
      order, so which of the two builds wins is not a property of the publication.
    2. The loser's NodeTypes are declined at adoption, which is what MeshWeaver#3768 measures on the
      staff portal: bundle_adoption: 0 of 25 adopted, every entry FrameworkDeclined, and seven node
      types unable to type their content — user-visibly, pages render empty.
    3. It blocks the release channel. the set is inconsistent and nothing rolls — the install's
      self-update is held on exactly this.

    What that means for this issue

    The decision this issue was opened to take is settled by evidence rather than by argument: a
    sibling that is another package's declared module must not ride. The implementation is
    MeshWeaver#3732 ("one producer" — the seal composes the published module bytes rather than a
    re-copied sibling), which is staged behind a maintainer sequencing call because it touches a
    workflow_call lane five satellites publish through.

    BakeHost.ShippedByHostProblem (#3175) still only checks the platform host's /app and surface
    manifest, so the second-bundle case this issue identifies remains unguarded — that gap is real and is
    the durable half. Worth keeping open for the guard, not for the question.

    Related, and all one cause: MeshWeaver#3732 (the producer), MeshWeaver#3768 (the consumer symptom),
    MeshWeaver.Manufacturing#67 (a gate that cannot register Skill/Agent for the same reason).

  5. rbuergi commented on Sep 9, 2026

    @rbuergi
    ContributorAuthor

    A live, daily-reproducing instance of this issue's question — and it names a break shape no gate sees

    This issue asks whether a sibling that is itself another package's declared module should ride into a
    second bundle. Here is the other half of module closure, failing every night since 2026-09-04 in
    MeshWeaver.Manufacturing's scheduled run
    (34307719499):

    [MeshWeaver.Mesh.IncompatibleModule] Module 'MeshWeaver.AI' did not install against this platform
    build and is CONTRIBUTING NOTHING. It requires
    'MeshWeaver.ContentCollections.Indexing.ChunkPosition
     (MeshWeaver.ContentCollections.Indexing — this deployment carries no such platform assembly)'.
    

    Why that assembly is missing — it LEFT the platform, on purpose

    core@main:     src/MeshWeaver.ContentCollections/          ← the only one left
                   (no MeshWeaver.ContentCollections.Indexing, and ChunkPosition appears nowhere)
    core history:  d136860e1  "The indexing pipeline, the map control and Cornerstone leave the
                               content surface (#2941)"
                   c6f0f7f3f  "refactor(indexing): the chunking pipeline leaves the content surface"
    Plugins@main:  src/MeshWeaver.ContentCollections.Indexing.Graph/
                   src/MeshWeaver.ContentCollections.Indexing.PostgreSql/
                   src/MeshWeaver.ContentCollections.Indexing.Test/  …
    

    So MeshWeaver.ContentCollections.Indexing is no longer a platform assembly — it is a module-side
    assembly in MeshWeaver.Plugins. And MeshWeaver.AI binds a type from it.

    The publication seals four bundles (upstream 'plugins' sealed 4 module bundle(s) for identity s1218dc0a…: AI, Essentials, Stripe, Maps). Indexing is not one of them, and the AI bundle evidently
    does not carry it either — the loader looks for it as a platform assembly, finds nothing, and
    refuses the whole module.

    The shape, stated generally

    An assembly that leaves the platform for a module breaks every OTHER module that binds it — at
    LOAD time, invisibly to every compile gate.

    Both halves compile perfectly: core builds without it, the AI module builds against it (it is right
    there in the same repo). The pair gate sees a public type leaving src/ and asks for a counterpart —
    which #2941 presumably had, since nothing failed to compile. What nothing checks is whether the
    assembly it moved into is in the closure of every module that binds it.

    That is this issue's question from the other direction: not "may a sibling ride into a second
    bundle?"
    but "must it?" — and the measured answer here is that when it doesn't, the depending
    module contributes nothing.

    The blast radius, end to end

    AI contributing nothing ⇒ its BuiltInAgentProvider / BuiltInSkillProvider never run ⇒ Agent and
    Skill resolve to nothing ⇒ Store fails on Skill ⇒ Store/Plugin (declared by Store) never
    registers ⇒ 36 package installs cascade ⇒ GATE FAILED — 103 NodeType(s) declared, NONE installed. MeshWeaver.Markdown.Collaboration fails the same way in the same run, on
    MeshWeaver.Blazor.BlazorView\2` — a second assembly that left the platform.

    🚨 Two modules, two departed assemblies, one shape — which is what makes this a closure rule
    rather than a one-off.

    Why it is worth having here rather than only on the satellite

    • It reproduces daily and deterministically, unlike the portal-side declines.
    • It shows the failure is not about Agent/Skill being unpublished (they are served by the AI
      engine's providers by design, Plugins@7afbd745) — I chased that first and it was wrong.
    • It shows a green push run proves nothing about the pairing: push pins the image to the wave the
      modules came from, so only the release-follow poll exercises it.

    Cross-refs: MeshWeaver.Manufacturing#67 (the observable), #3768 (the registry/identity pairing),
    #3732 (the two-producer half), #3583 (the same asymmetry from the source side).

  6. rbuergi commented on Sep 10, 2026

    @rbuergi
    ContributorAuthor

    Reopened — the 2026-09-10 sweep was not a triage decision

    This issue was closed not_planned at 2026-09-10T14:31:41Z by a bulk sweep that PATCHed every open issue in all 13 Systemorph repositories — 311 issues in twelve minutes, no comment on any of them — after a session was asked to "close all issues in all repos". Nothing on this thread was read, and no judgement was made about this issue.

    All 82 issues the sweep closed in the MeshWeaver fleet were reviewed afterwards. This one is unresolved: Open design decision with live, daily-reproducing instances; the question is answered empirically but not in code.

    Sixteen of the 82 were left closed — their fix had actually landed, or the author had withdrawn them; those should have read completed, not not planned.

  7. added 2 commits that reference this issue on Sep 11, 2026
  8. rbuergi commented on Sep 11, 2026

    @rbuergi
    ContributorAuthor

    A live instance, measured this morning — and it is still red after #3933

    This issue asks whether a sibling that is itself another package's declared module should ride into a second bundle. MeshWeaver.Crm's gate answered it concretely at 2026-09-11T06:12:18Z (run 34478221819, shard 1/1), and the two lines are adjacent in the log:

    composed AI          from the sealed publication of 'plugins' for identity s453e5561… (ai.module.nupkg)
    composed Essentials  from the sealed publication of 'plugins' for identity s453e5561… (essentials.module.nupkg)
      sealed 4e241ee5f89308a2 as a sibling riding ai.module.nupkg
      sealed 60a97251baddb425 as the declared module of essentials.module.nupkg
    ##[error]MeshWeaver.Markdown.Collaboration reaches this mesh as more than one build
    

    MeshWeaver.Markdown.Collaboration is the declared module of Essentials AND a sibling riding AI, at two different builds — 4e241ee5f89308a2 vs 60a97251baddb425. The gate's own inventory line counts it "in 15 copies and 3 builds".

    Why this matters now: the one-workspace fix does NOT close it

    MeshWeaver#3933 (platform, merged 2026-09-10T18:57Z) and the Plugins consolidation that followed both attack divergence between compilations — build the set once, get one MVID. That is necessary and it landed: Plugins main now calls node-repo-module-pack once (was twice), and its Plugin Catalog CI has been green since 04:49Z.

    It is not sufficient, because the two copies here are not two compilations of one lane's set — they are one module reached by two different closure paths, one as a declared module and one as a riding sibling. Nothing about compiling in a single workspace makes those the same artifact, so the refusal in compose-sealed-modules.sh still fires.

    The cost, measured

    This single condition is the sole required-check failure on every satellite PR still open this morning — MeshWeaver.Crm#81/#84/#86, MeshWeaver.Reinsurance#191, MeshWeaver.Manufacturing#78, MeshWeaver.Education#306 (its Disposable-mesh gate fails on test-repos) — and it was the same on the two MeshWeaver.SocialMedia PRs before they merged. The fleet cannot drain while it stands.

    What the decision actually is

    The gate is correct and should not be loosened — a set carrying two builds of one assembly means the bake stamps every NodeType against whichever copy loaded first, and every consumer that loaded the other declines them (#3732, Doc/Architecture/ModuleOwnedSiblingsRide). So the choice this issue has been holding open is now load-bearing:

    1. A declared module never rides as a sibling — Essentials owns Markdown.Collaboration; AI takes a dependency on the bundle rather than absorbing a copy. Removes the second build by construction.
    2. A riding sibling is deduplicated against declared modules at seal time — the publication drops the riding copy when another bundle in the same sealed set declares that assembly.

    Option 1 is the closure rule this issue proposes; option 2 is a seal-time repair that leaves the closure rule wrong but unblocks sooner. They are not mutually exclusive, and the fleet is red until one of them exists.

    Refs #3732, #3933.

  9. rbuergi commented on Sep 11, 2026

    @rbuergi
    ContributorAuthor

    Correction to my comment above — its central claim was falsified 47 minutes after I posted it

    I wrote that the one-workspace fix "does NOT close it" and that "the fleet cannot drain while it stands." Both are wrong, and the measurement that shows it is the same gate on the same PR, one run later:

    06:12:18Z 06:58:18Z
    framework identity s453e556166cdcadeb087d9cf97672ddc s426f089fcf95b69eb383d55f8af0b8ac
    Markdown.Collaboration 4e241ee5f89308a2 riding ai.module.nupkg + 60a97251baddb425 declared by essentials.module.nupkg one copy, declared by essentials.module.nupkg
    verdict ##[error] … more than one build success

    MeshWeaver.Crm#86 merged at 07:00Z on the second one.

    What I got wrong: I read a red gate as evidence about the closure RULE, when it was evidence about WHICH PUBLICATION that run resolved. The 06:12Z run composed a set sealed under the previous platform identity — sealed before the Plugins consolidation landed. The consolidation could not retroactively fix an already-sealed publication; it only governs sets sealed after it. One fresh core CD later, the same repo, same PR and same gate went green untouched. So #3732/#3933 did close this, and the correct reading of a stale-identity red is "this publication predates the fix", not "the fix is insufficient".

    This is the shape AGENTS.md warns about — a snapshot of a transition read as a property. The discriminator I should have checked before drawing a conclusion was the identity string in the gate's own first line, which names exactly which publication it is judging.

    The two options I offered are also moot, and their shared premise was wrong: I assumed the ride itself was the defect. #4006 settles that with the measurements — the sibling DOES ride, exclusivity was never available (AI requires [Store] while Essentials, which declares the assembly, requires [AI, …] — a package cycle), and what makes it safe is one build per name per publication, asserted by the producer at compose time and by the consumer over the sealed set. That is the right answer and it is not either of mine.

    The cost figures in my earlier comment were accurate when measured and are now historical: the satellite PRs that failed on this condition are draining — Crm#86 merged, and the SocialMedia pair merged earlier.

  10. rbuergi commented on Sep 11, 2026

    @rbuergi
    ContributorAuthor

    Answered. Closing.

    This was a design question, not a bug report, and #4006 (merged) settles it with measurements
    rather than reasoning. Both files verified on main = ac7617466 by path —
    Data/Architecture/ModuleOwnedSiblingsRide.md and .github/workflows/node-repo-module-pack.yml —
    because the merge went through the queue and the closing keyword did not fire.

    The answer

    Yes — it rides, IFF the platform host does not already ship it. Being another package's declared
    module is not a reason to exclude it.

    Two things make that the right answer rather than a shrug:

    1. Exclusivity was never available. AI requires [Store] while Essentials — which DECLARES
      the assembly — requires [AI, …]. That is a package cycle, so "the declaring package alone ships
      it" cannot be expressed, and enforcing it would break a supported install.
    2. What makes the ride safe is not exclusivity but ONE BUILD PER NAME PER PUBLICATION — asserted
      by the producer at compose time and again by the consumer over the whole sealed set. The hazard
      this question was really about was never two bundles, it was two different builds under one
      simple name
      : the loader keeps whichever it sees first, the loser's bytes are never in memory, and
      callers compiled against the loser silently run the winner.

    Why the question dissolved rather than needing new machinery

    Everything that made it hard moved after it was opened:

    Measured, with denominators

    MeshWeaver.Plugins main @ d6fa0338: 79 index.json scanned, 37 declare a
    content.module, 148 .csproj under src/. 18 of 37 declared modules transitively
    reference another package's declared module; 4 of 37 are actually ridden —
    Markdown.Collaboration ×14, AI ×13, Maps ×4, Import.

    So the practice is common (18/37 could ride) and the reality is narrow (4/37 do) — which is why the
    question looked more alarming from the reference graph than it turned out to be in the artefacts.

    Also fixed in passing

    The one diagnostic line that says which witness answered — a small thing, but the kind that makes
    the next investigation of this area start from a true statement instead of a plausible one.

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions