Skip to content

Feature: Feature Modules - #98

Merged
jhweir merged 10 commits into
devfrom
feat/feature-modules
Aug 1, 2026
Merged

Feature: Feature Modules#98
jhweir merged 10 commits into
devfrom
feat/feature-modules

Conversation

@jhweir

@jhweir jhweir commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

PR: Feature Modules — The Missing Rung

Summary

WE's vision names feature modules as a distinct contribution type, a design guide specifies
packages/modules/<name>/ in detail, and the marketplace plan lists four distributable types — none
of which is a feature module.

This PR builds the rung. A feature module is the thing above blocks: a bundle of stateful
capability
that installs into a space and can be placed by a template — where templates and themes
are data, and elements/components/widgets are stateless presentation.

Two modules ship with it, chosen to test different halves of the contract, and both verified running
in the real app.

Branch: feat/feature-modules · 10 commits · 39 files · +1,868 / −80 · 733 tests

Design rationale in full: feature-modules.md.


What's in it

@we/schema-shared — the contract

ModuleDefinition, SlotAnchor, SlotContribution, ModuleCapability, ModuleStoreDeps,
defineModule, checkModuleCompatibility. Declared in the neutral package for the same reason
dataSource.ts is: a module must describe itself without importing a host, a framework, or a backend.

@we/app-framework — the host side

file role
registries/slotRegistry.ts replaces shellRegistry; open collection of persistent chrome
registries/moduleRegistry.ts fourth registry, same shape as the four that existed
registries/bundledModules.ts which modules this build contains, and seed-driven activation
types/seed.ts modules: string[]

The modules

  • @we/module-globe — the Cesium conversion. Only the wiring moved.
  • @we/module-notes — a per-space scratchpad, the first module to own durable entities.

Design decisions worth reviewing

Framework code is optional, and that is load-bearing

components is the only field that can hold framework-specific values, and it is optional. A module
shipping schema fragments imports nothing framework-shaped, because in a fragment Column is a
registry key, not an import
— so it renders on any framework whose renderer registers that key.

That is not tidiness. An externally-loaded bundle carrying its own reactive runtime gets a second
one, and reactivity silently stops crossing the boundary — no error, just components that never
update. Fragments-first is what makes dynamic loading tractable later, and notes proves it is
practical: an entire panel with zero framework imports.

Omitted means agnostic

backends and frameworks omitted mean portable. Making the portable case the default is what forces
coupling to be opted into and declared rather than happening quietly. Notes declares
backends: ['ad4m'] because owning entities means writing @Model-decorated classes until a
manifest→SDNA compiler exists — the escape hatch working as designed: unblocked, with the coupling
visible at install.

Capabilities are declared, not enforced

Nothing prevents a module calling getUserMedia without saying so. They exist to be displayed
the browser's model of showing the request and the origin, never a computed risk score, since a score
derived from unenforced declarations manufactures false confidence. They are the hook enforcement
attaches to if a permission broker is ever built.

Anchors order and group; they do not position

The faithful-generalisation constraint. WE's shell nodes position themselvesbootScreen is a
full-bleed $if, sidebar carries its own position prop — so wrapping each anchor in a container
would have changed how all three render. overlay sorts first specifically to reproduce the previous
[bootScreen, sidebar, templateEditor] order, and a test pins it.

Ordering falls back to declared order, then id. Entries come from a Map, so without the
tiebreak chrome would rearrange depending on which module loaded first.

Templates cannot configure shell chrome

Resolved to no, on scope rather than safety: shell chrome is app-lifetime and cross-space, a
template is per-space, so "this template hides the call bar" is incoherent the moment you navigate
into a space whose template disagrees while the call lives elsewhere. TemplateProvider renders the
shell outside the keyed Router precisely so it survives template switches.

This deleted a whole design surface: no $shell directive, no per-contribution override levels, no
template-facing slot API. Seed, agent preference, and the module's own state are the three
configuration paths.

The predicate namespace is a one-way door

module://<moduleId>/<property>, named in NOTE_PREDICATES rather than inlined, because this is the
first module to own entities and whatever it picks becomes the convention. Never we://, which
belongs to WE's own models and would collide with TextBlock.text once both are in one perspective.
Predicates are how existing data is found, so changing the scheme later orphans everything silently.


What the build changed from the plan

  • storeRegistry was dropped as unnecessary. moduleStores already puts module state in the bag
    under modules.<id>.
  • Two contract additions the notes module forcedmodels (declarative, so the host owns the
    install mechanism and idempotency lives in one place) and ModuleStoreDeps (reactivity injected
    rather than imported, the same port trick that keeps @we/schema-shared framework-neutral).
  • Install/consent and dynamic loading deferred, on the principle applied throughout: land with the
    consumer. Everything is bundled and seed-declared, so there is no install path to gate.

Testing

Verified: 601 tests in @we/schema-shared, 39 in @we/schema-solid, 132 in @we/app-framework.
All packages typecheck, lint clean, 13 template schemas validate.

Verified live, four times: the globe after conversion, the globe after seed-driven activation, the
notes launcher, and finally notes persisting a note in a personal space — which exercises the whole
chain: seed activation → module registration → SDNA install → model-class registration → live
$querymodel.create.

Four bugs, all in seams, none caught by tests

Every one was found by running the app, and every one sat between two things each verified in
isolation:

  1. Model "Note" not found in registry — SDNA install puts the shape in the perspective;
    registerModel puts the class where model.create resolves it. Only the first was wired.
  2. A module installed, registered, and invisible — chrome contributed vs. chrome reachable. The
    panel was gated on state nothing could change.
  3. A toggle that silently did nothing$action destructured exactly two segments, so
    modules.notes.toggle resolved an object rather than a function. A consequence of choosing
    modules.<id>.* without checking $action supported the depth; $store had always walked
    arbitrary depth, so the two resolvers disagreed and only one was ever exercised.
  4. No SHACL shape stored for class 'Note'switchPerspective installs SDNA only into a
    perspective with no shapes, so a foreign perspective is never silently converted. Modules cannot
    follow that rule, because a module can be enabled after a space exists.

A green suite says nothing about a join. It is also a retrospective argument for the
convert-something-that-works strategy: the globe's fixed behaviour was the only available reference,
and every bug that appeared was in the module the globe did not resemble.


Known follow-ups

  • Space.enabledModules — live debt. A community cannot turn a module on or off, so chrome
    appears in every space. Untidy for notes, objectionable for the call module, where a community
    that never asked for video would get a call bar. Close it alongside calls.
  • Uninstall semanticsunregisterModel detaches the class; SDNA and data remain. A deliberate
    decision, not a side effect of disabling a module.
  • AgentSettings.installedModules + consent prompt, dynamic loading, meta.requiresModules,
    a permission broker — all marketplace-era.

What this unblocks

The call module is now the smallest remaining piece: channel('rtc') from the ephemeral port, a
dock-bottom slot, a store, and its stage view as the one Tier-2 framework component. Presence
already carries the roster as a call activity.

backend-ports-and-package-split.md is downstream of this:
its commits 5–6 extract the editor and AI as modules following the @we/module-globe pattern. That
will stress the module system at a scale nothing here approached — the editor alone is 7,557 LOC
against globe's ~90.

jhweir and others added 10 commits August 1, 2026 16:09
The rung above blocks: what a module contributes, and what a host must accept.
Declared in the neutral package for the same reason dataSource.ts is — a module
must be able to describe itself without importing a host, a framework, or a
backend.

**Framework code is optional, not assumed.** `components` is the only field
that can hold framework-specific values. A module shipping schema fragments
only imports nothing framework-shaped, because in a fragment `Column` is a
registry key rather than an import — so the fragment renders on any framework
whose renderer registers that key. That is not just tidy: an externally-loaded
bundle carrying its own reactive runtime gets a *second* one, and reactivity
silently stops crossing the boundary. Fragments-first is what makes dynamic
loading tractable later.

**Omitted `backends`/`frameworks` mean agnostic.** Making the portable case the
default is what forces coupling to be opted into and declared. An entity-owning
module must currently say `backends: ['ad4m']` — there is no manifest→SDNA
compiler, so its models are decorated classes. That is the escape hatch working
as intended: entity-owning modules stay unblocked while the coupling is visible
at install.

**Capabilities are declared, not enforced.** Nothing prevents a module calling
getUserMedia without saying so. They exist to be *displayed* — the browser's
model of showing the request and the origin, never a computed risk score, since
a score derived from unenforced declarations manufactures false confidence.
They are the hook enforcement attaches to if a permission broker is built.

checkModuleCompatibility mirrors planQuery/planEphemeral: refuse loudly at
registration rather than half-mounting something that cannot work, and report
every problem at once so an install prompt can show them together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e registry

Generalises `shellRegistry` — three named keys typed `typeof shellRegistry` —
into an open `slotRegistry`, because a module must be able to *add* chrome
rather than override one of three. A call bar, a notifications tray, a
mini-player and an offline banner all want the same thing.

**Anchors order and group; they do not position.** This is what makes it a
faithful generalisation rather than a rewrite. WE's shell nodes position
themselves — bootScreen is a full-bleed $if, sidebar is a CollapsibleSidebar
carrying its own position prop — so wrapping each anchor in a container would
change how all three render. An anchor is semantic metadata that fixes order in
the flat output; the node still positions itself. `overlay` sorts first
specifically to reproduce today's [bootScreen, sidebar, templateEditor], and a
test asserts exactly that.

Ordering falls back to declared `order`, then to **id**. Entries come out of a
Map, so without the tiebreak equal-order chrome would rearrange depending on
which module happened to load first — the same reason presence sorts on
agentId.

The seed's white-label override moves from assigning `shellRegistry.bootScreen`
to `slotRegistry.replace('core:bootScreen', node)`. Deployment-level
configuration of app-level chrome: the layer whose scope matches, unlike a
per-space template, which has no channel into the shell and shouldn't.

`moduleRegistry` is the fourth registry beside appRegistry, modelRegistry,
templateRegistry and themeRegistry, and deliberately the same shape — modules
become the next thing following an existing pattern rather than a new concept.
Registering fans a definition out to the registries that already exist and
holds the module's store under `modules.<id>`. It refuses an incompatible
module loudly rather than half-mounting it, and applies nothing partially.

`modules: {}` is always in the stores bag even with no modules registered.
$store's single-segment path indexes the store object without a guard, so
`{ $store: 'modules.notes' }` would throw on a missing key rather than
returning undefined — and returning undefined is precisely what makes
`$if` on `modules.<id>` the supported way for a template to depend on an
optional module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`packages/modules/globe/` — the sufficiency proof for the contribution points,
and deliberately a conversion rather than a new feature. The globe already
worked, so its behaviour is a fixed reference: if the module system can carry
something built before the module system existed, the seams are real. Writing a
new feature to test new seams means a failure can't be attributed to either.

**Only the wiring moved.** CesiumGlobe stays in @we/widgets — it passes the
props-only test, since layerFactoryRegistry is injected and its LayerStore is a
private Map rather than a WE store. The layers stay in @we/cesium-layers. What
moved is the layer set and the component wrapper, previously scattered through
componentRegistry.tsx, which no longer imports @we/cesium-layers at all.

**The module owns no store**, and that is the honest outcome rather than an
omission: layer visibility is `$local` state in the route schema, so inventing
a store would be new behaviour and would break the "identical afterwards"
property the conversion exists to demonstrate. Worth having as the first
example so nobody assumes stores are mandatory.

createGlobeModule takes the widget as an argument rather than importing it, so
the package never pulls Solid or @we/widgets into its own bundle — the host
passes in the component it already has. That preserves the single-instance
guarantee that starts to matter once modules load dynamically.

It exercises defineModule, the registry, a **module-private sub-registry** (the
layer factories — a shape the call module will never test), a framework
component contributed for a genuinely imperative core, and compatibility
declaration. It does not exercise shell slots (a route is not chrome; the
host-slot migration covers those) or the ephemeral port.

Rendering needs WebGL and a Cesium Ion token, so visual verification is manual.
What is asserted is the part that actually moved: the layer set resolves
identically from its new owner, every entry is a callable factory, and the
module declares itself honestly — backend-agnostic because it owns no entities,
solid-only because its imperative core really is a framework component.

Adds packages/modules/* to the workspace globs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unrecognised type threw, which took down the **whole render** rather than
one node. So a template referencing a component from a feature module that
isn't enabled produced a blank page — exactly the failure the module system's
optional-dependency story depends on not happening, and the reason a template
declaring `requiresModules` or `$if`-ing on `modules.<id>` could never have been
a complete answer on its own.

Now it fails the way the rest of the system does: loud, but scoped. A visible
placeholder naming the component and the likely cause, plus a console error,
while its siblings render normally.

Dev-time loudness is not lost. `we-validate-schemas` already catches unknown
types before runtime, which is the right place to catch a typo — 13 schemas
validate on every run. What changes is only the runtime, where one bad node
should cost that node and nothing more.

Updates the test that pinned the old behaviour, and adds one to the portable
slice proving siblings survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the globe an actually-registered module rather than a package the app
happens to import. `we-seed.json` gains a `modules: ['globe']` key — "which
modules to include" is already in the seed's stated purpose, so this is the
deployment layer of the enablement story. The per-agent and per-space halves
(AgentSettings.installedModules, Space.enabledModules) arrive with the
marketplace, when modules become installable rather than bundled.

Bundled modules are **factories, not definitions**, because a module may need
something from the host to describe itself. The globe takes CesiumGlobe as an
argument so its own package never imports Solid or @we/widgets — which keeps
those single instances shared with the host, the property that starts to matter
the moment modules load dynamically.

That injection is also why `initializeIntegrations` now takes a deps argument
rather than importing the component registry: it lives in framework-neutral
`shared/`, and importing the Solid registry there would drag the whole
component tree into it. `platform/context.tsx` hands the components over, where
the framework is already known.

Activation reports rather than throws, and distinguishes two faults that are
easy to collapse: **missing** (the seed names a module this build lacks) from
**refused** (present, but incompatible with this host). Collapsing them would
send someone hunting for a packaging problem that isn't there. A silently
missing module would otherwise surface much later as an unexplained missing
component — which is precisely the confusion the renderer's new placeholder has
to name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second module, chosen for exactly what the globe could not test: a
module-declared model, its install path, and the predicate namespace. Fully
solo-testable, because a personal perspective is local-only — no neighbourhood,
no Holochain sync — so it exercises the install path without peer connectivity.

**It is framework-agnostic despite shipping a whole panel.** Every piece of UI
is a SchemaNode; nothing in the package imports Solid or @we/components,
because in a fragment `Column` is a registry key rather than an import. That
makes it Tier 1 of the convention, and it means this module has no framework
import to duplicate — so it cannot introduce the second-runtime hazard when
modules eventually load dynamically.

**It ships almost no code.** Notes are a live `$query` in the fragment;
creating and deleting go through `model.create`/`model.delete`, already in the
stores bag. A module reaching for its own persistence layer would be
duplicating the data port. The store holds only panel open/closed — and that
*is* store state rather than `$localState`, because the panel is chrome and
node-local state would reset it on every route change.

Two contract additions the module forced, which is what a foil is for:

- **`models`** — declarative, so the *host* owns the install mechanism.
  Module models now install through the same `ensureModelsRegistered` path as
  WE's own, which diffs against the perspective before writing. Idempotency in
  one place matters here specifically: `cleanupSpaceSdna` exists because shapes
  once got installed twice by different agents, and N modules each rolling
  their own install is that bug with more instances.
- **`ModuleStoreDeps`** — reactivity is *injected*, not imported, the same port
  trick that keeps @we/schema-shared framework-neutral. Solid's createSignal
  already has the [read, write] shape, so the host lends it and the module
  stays framework-free.

`backends: ['ad4m']` is declared, because owning entities means writing
@Model-decorated classes until a manifest→SDNA compiler exists. The escape
hatch working as designed: unblocked, with the coupling visible at install.

NOTE_PREDICATES names the scheme rather than inlining it, because this is the
first module to own entities and **whatever it picks becomes the convention**.
`module://<moduleId>/<property>` — never `we://`, which belongs to WE's own
models and would collide with TextBlock.text once both are in one perspective.
Predicates are how existing data is found, so changing the scheme later orphans
every note silently; a test pins the shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A module-owned entity needs **two** registrations, and missing either fails at a
different moment:

- SDNA install (`installSpaceSdna` → `ensureModelsRegistered`) puts the *shape*
  in the perspective.
- `registerModel` puts the *class* where `model.create` / `model.delete` /
  `$query` can resolve it by name.

Only the first was wired. So the notes panel rendered, the query returned
nothing, and adding a note threw `Model "Note" not found in registry` — a
failure well away from its cause, since everything visible looked correct.

The gap was easy to miss because `SPACE_MODELS` registers itself in
SpaceStore.tsx:75, so WE's own models were always present and the split never
had to be noticed. The existing tests didn't catch it either: they asserted
`moduleRegistry.models()` returned the model and that SDNA install included it,
which are both true and neither of which is the thing that was broken.

Adds `unregisterModel` for symmetry on module unregister. Note it detaches only
the class — SDNA already installed in a perspective, and data written through
it, remain. Removing those is uninstall semantics, which is a deliberate
decision rather than a side effect of disabling a module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module shipped with only the expanded panel — gated on
`modules.notes.open`, which starts false — plus a `toggleButton` fragment that
no template placed. So it registered successfully, installed its SDNA, and was
completely invisible with no way to open it.

Chrome gated on state that nothing can change is not chrome. A module has to be
reachable on its own, without depending on a template choosing to place its
trigger: a template *may* place `toggleButton` wherever it likes, but that
cannot be the only route in.

The dock-right slot now renders a launcher tab when closed and the panel when
open — the collapsed/expanded pair a docked panel actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e.method

`token.$action.split('.')` destructured exactly two segments, so
`modules.notes.toggle` resolved `stores.modules.notes` — an object rather than
a function. The handler was silently dropped: the button rendered, clicked, and
did nothing, with no error anywhere.

That is a direct consequence of choosing `modules.<id>.*` as the store
namespace without checking that $action supported the depth. `$store` has
always walked arbitrary depth via walkPath, so the two resolvers disagreed
about what a store path is — and only $store's version was ever exercised,
because every existing store sat at the top level.

Now the last segment is the method and everything before it is the path to its
owner. Accessors are deliberately *not* invoked while walking, unlike
`$store`'s walkPath: a store namespace is a plain object, and calling a signal
on the way to a method would be wrong.

Warnings now name the full path rather than just the first segment, which is
what made this hard to spot — "store modules not found" would have been a lie,
since `modules` was present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es to a space

Two faults, one of them a design gap rather than a bug.

**Module shapes never reached an existing space.** `switchPerspective` only
installs SDNA into a perspective with *no* shapes at all — deliberately, so a
foreign perspective (a Flux community with its own shapes) is never silently
converted into a WE space. But that rule cannot apply to modules, because a
module can be enabled **after** a space exists, which is the normal case the
moment modules are installable rather than bundled. So every existing space
failed any module query with "No SHACL shape stored for class 'Note'" from a
perspective that otherwise looked perfectly healthy.

`installModuleSdna` is therefore separate from `installSpaceSdna` and runs on
every switch into a WE space. `ensureModelsRegistered` diffs before writing, so
it is a read in the common case, and idempotency stays in one shared path — the
reason `cleanupSpaceSdna` exists is shapes installed twice, and per-module
install routines would be that bug with more instances.

**The panel was app-level chrome over space-level data.** Notes are written
into the current dataset, so offering the panel where there is no dataset is an
invitation to lose what you typed. It now hides entirely outside a space.

That is the crude version of a question the module system hasn't answered:
*which* spaces should show it. `Space.enabledModules` is the real answer — a
community turning a module on for its space — and it arrives with the
marketplace alongside consent. Until then a module's chrome appears in every
space, which is acceptable while modules are first-party and bundled, but it is
a gap rather than a decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we failed. Why did it fail? →

Name Link
🔨 Latest commit fceeefc
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a6e1939e3dabf0007a7af64

@jhweir
jhweir merged commit e36fc51 into dev Aug 1, 2026
0 of 5 checks passed
@jhweir jhweir changed the title Feat/feature modules Feature: Feature Modules Aug 1, 2026
@jhweir jhweir mentioned this pull request Aug 1, 2026
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.

1 participant