The home surface — apps launch, spaces list
The user home has exactly two regions and one rule between them.
| Region | What it lists | Where it comes from |
|---|---|---|
| Apps | What you launch — a plugin, a course, a hub, Threads | the viewer's own {owner}/_App/* records, one partition-local query |
| Content | The spaces you can reach, plus your own home items | namespace: … nodeType:Space ∪ namespace:{owner} … is:content |
Nothing belongs to both regions. An app that also lists as content says the same thing twice, in two visual languages, and the reader has to work out that they are the same thing.
The root level of the content list is an ALLOW-list
UserActivityLayoutAreas's root leg is nodeType:Space — nothing else. It used to be a deny-list
(-nodeType:Store/Plugin -nodeType:Store/Catalog -nodeType:User) and the difference is not
cosmetic: a deny-list is only ever as complete as the last person who remembered to extend it, and
the home is precisely the surface that cannot know what someone will install next.
Measured on memex.meshweaver.cloud, 2026-08-30: the home listed Posts under a "Posts Hubs"
heading and Event under "Event Hubs". Both are partition roots of perfectly ordinary NodeTypes
(SocialMedia/PostsHub, Marketing/EventHub) that nobody had thought to add to the list. With an
allow-list a new root NodeType has to opt in, and the failure mode of forgetting is "my thing is
not on the home page" rather than "everyone's home page grew a section".
A hub is not an exception to the rule — it is the rule. A publishing hub is something you launch, so
it gets an app tile ({viewer}/_App/{id} with content.plugin naming the hub) and stays off the
content list like every other app.
The own-partition leg is NOT type-filtered — the NODE opts out instead
Your home partition contains no Spaces at all, so filtering that leg by type would empty the list of
everything you ever authored. What keeps app chrome out of it is a property of the node:
ExcludeFromContext: ["content"], which is:content honours at query time.
An install lands a workspace root at {viewer}/{packageId} — AppleMaps — my workspace,
ThinkInStreams — my workspace. That node is the landing page of an app whose tile is on the band
directly above it. Localizer.BuildHomeRoot stamps the opt-out; Localizer.ReconcileHomeRoot heals
one an install touches again; AppTileRefresh.HomeRootHeals reaches the roots no install will ever
touch again, on the store render per viewer and mesh-wide under RefreshAppTiles. One viewer's home
listed forty of them before that landed.
🚨 A root is a root because the viewer's own INSTALL MANIFEST says the package landed there — the
recorded installedPath, else the standard target. Never because of how the node is named. A node
you created yourself is untouched however closely it resembles the pattern.
A tile's name and icon are COPIES, so they converge
The Apps grid paints from query rows alone — MeshNode.Name and MeshNode.Icon, no per-tile hub
activation and no content read. That is what makes it fast, and it means the tile's display identity
is a machine-stamped copy of its package's, stored on the record.
A copy that no longer matches its source is exactly what a migration exists to fix, so
AppTileRefresh converges both fields to the package's, with one explicit escape hatch each:
| field | converges to | the viewer's own is kept when |
|---|---|---|
Icon |
Localizer.HomeRootIcon(package.Icon) |
content.customIcon == true |
Name |
package.Name |
content.customName == true |
The escape hatch is always declared, never inferred. "It looks hand-edited" is not a signal a migration can act on.
Two things this must not do, both learned the hard way:
- It must not write when nothing is wrong. A record whose name already matches its package needs
no bookkeeping key stamped on it. Otherwise every mesh-wide sweep rewrites every tile and reports
a four-figure "refreshed" count that means nothing. (See also
NeedsMainNodeMirror: an upsert cannot moveMainNode, and two sweeps once reported 1390 and 839 records refreshed while moving not one.) - It must not fight the other writer.
Edu/Module'sCourseAppTilestamps a learner's progress onto the sameName({base} · {opened}/{total}). The two agree throughcontent.tileBaseName: the rename writes the authoritative base from the package, the stamp re-derives its suffix from that same base. A converged tile is rewritten by neither.
The accretion, and why the fallback strips
CourseAppTile reads its base back from tileBaseName and falls back to the record's current name
only when that key is missing. That fallback is right exactly once — the first stamp. If the key is
ever lost (a writer replaces the content object without round-tripping it), the next stamp reads an
already-stamped name as its base and appends to it, and every stamp after that appends again. On
memex one tile had reached:
AdvancedBusinessRules · 1/17 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14
CourseAppTile.Unstamped therefore strips whole trailing · n/m groups in that fallback: a lost
base must cost at most the tail it can no longer explain, never a longer name. It strips only
whole groups, so no ordinary name is touched; a package legitimately named Fractions · 1/2 loses
its tail only in the window before a store render, because AppTileRefresh.Renamed writes the base
from the package's own name and is authoritative over the heuristic.
Where each rule lives
| Rule | Code |
|---|---|
| Root level of the content list is Spaces | core MeshWeaver.Graph/UserActivityLayoutAreas.RootTypeFilter |
| A workspace root is not content | Store/Installer/Source/Localizer.{BuildHomeRoot,ReconcileHomeRoot,WithoutContentContext} |
| …and the roots already out there heal | Store/Installer/Source/AppTileRefresh.HomeRootHeals |
| A tile's name/icon converge to its package | Store/Installer/Source/AppTileRefresh.{Renamed,Refreshed} |
| A learner's progress rides on the same name | Edu/Module/Source/CourseAppTile |
| The mesh-wide migration | Store/Maintenance → RefreshAppTiles |