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.
The Apps band is a FILTER over one query — categories are a dimension, not a place
The band has had three shapes in three days, and the two it discarded say what the rule is.
| Shape | Why it went |
|---|---|
| Every group's tiles at once, section under section | With a dozen groups it is a wall of icons and no overview. |
| An overview of FOLDER tiles you click into (2026-09-14) | A launcher that makes you navigate before you can launch. Every app became two clicks, and the overview was a place to get lost in. |
| One row of chrome over one grid (2026-09-15) | The grid always shows apps. The chrome says WHICH apps and HOW MUCH of each. |
The chrome is one row, beside the title: a filter combobox, a filter box, and a view switch.
| Filter | Shows |
|---|---|
| Recent | The apps this viewer actually opened, most recent first — the ONE slice whose order is the access log's rather than the arrangement's. |
| Favorites | The apps they marked (User.PinnedPaths — the same pin the rest of the product uses, so a favorite app is a pinned node). |
| All | Everything, grouped by category with its headings — the sectioned grid, which is also the only place a tile can be dragged BETWEEN categories. |
| one entry per category | That category's apps, heading dropped (the combobox already names it). |
| a declared scope | A different QUERY the control declared (Spaces), switched through SelectScope like any other scope. |
Each entry carries its count, so an empty slice says so before it is picked, and every empty state names the way out of it ("Nothing opened yet — pick All to see every app").
Three views, one surface: Icons (the phone grid, and the only rearrangeable one), Cards (icon,
name, the package's own description) and Rows (the same on one full-width line each). The filter
and the view are remembered per launcher in the browser — localStorage, keyed by the band's query,
so two users on one browser never share a memory — and read on first render BEFORE the result source
starts, so the viewer lands on their slice rather than on the default and then a jump.
🚨 Every control here slices rows the band's ONE query already returned. The filter box is a
filter, not a search: it matches every typed word against the row's name and description and never
becomes a query. The only thing that re-queries is picking a declared scope. That is the two-stage
rule holding (see apps-load-two-stage): one anchored, contentless query paints the grid, and the
access log, the marks and the click targets all arrive as second passes that re-project in place.
Drag-and-drop is alive only where the sections ARE the arrangement — All, one category, or a declared scope. Under Recent or Favorites a drop would write a position the view does not paint, so the grid is not rearrangeable there; the tile's right-click menu moves it instead.
The ⋯ menu is the category's second hand — and the discoverable one
A tile's menu offers: open it, mark it, file it under any category the viewer's apps carry, a new
category, or no category. Every one of those writes through the SAME arrangement path a drop uses
(TileArrangement.Move → group, order, customGroup), so the two cannot drift apart. It works
in every view, which is what makes the categories usable where dragging is not — cards, rows, a
phone.
🚨 It opens from a visible ⋯ button as well as from right-click, and the button is the one that matters. A right-click menu is invisible: nobody discovers it, a touch screen has no right-click at all, and a keyboard cannot reach it — so the only way to re-file an app would have been a gesture most viewers never try. The ⋯ appears on hover or focus on a pointer device and is always visible where there is no hover, which is the Microsoft 365 apps page's affordance for the same job (Roland, 2026-09-15: "get inspiration from apps screen in m365"). The same page is why a card lifts on hover, clamps its description to two lines, and carries its category as a chip — under Recent or Favorites the sections have no headings, so the chip is the only thing that says where an app lives.
A tile's GROUP is a stamped copy too — so it converges like the name and icon
The Store stamps the package's category as the tile's starting group. Until 2026-09-14 the heal
filled only a MISSING group, so re-categorizing a package reached future installs and nobody who
already had the tile. The group now follows the same rule as the other stamped copies:
| field | converges to | the viewer's own is kept when |
|---|---|---|
Icon |
Localizer.HomeRootIcon(package.Icon) |
content.customIcon == true |
Name |
package.Name |
content.customName == true |
Description |
package.Description |
never — see below |
Group |
package.Category |
content.customGroup == true; or the group is "" (a deliberate ungrouping); or the group differs from content.stampedGroup; or — with no stamp — the group names no category any package declares |
Two markers, because the viewer's choice must be recognisable BOTH ways round:
customGroupis written by every drag, drop, rename and menu move — declared, never inferred.stampedGroupis what the MACHINE last wrote (Localizer.BuildAppRecordat mint,Localizer.WithGroupat every convergence). A group still equal to its stamp was never regrouped and may follow the package; one that differs is the viewer's, flag or no flag.- A legacy record (minted before either marker) converges only when its group names a category
some package declares — the
knownCategoriesAppTileRefresh.Plancollects from the package roots — because that is a value the Store itself could have written; "Work" or "Mine" is nobody's category and stays.
Description has no escape hatch, deliberately: no surface anywhere lets a viewer write a tile
description, so every value it can hold is a machine-stamped copy of the package's and converging it
can undo nobody's choice. It exists because the card and row views paint it off the query ROW — the
grid never reads content — so a record without one advertises the app as "Ask the agent to create a
description".
A record that already matches is not written (the mesh-wide RefreshAppTiles sweep must report zero
for a converged mesh).
Every package declares a CATEGORY and a DESCRIPTION — and the taxonomy is one file
Both are dimensions of a surface, not decoration, and both are now PR gates
(validate-repos.py → check_package_categories):
- the category is what the store browses by, what this filter is built from, and what the tile
carries as its group. A package with none rendered under the
Platformfallback with no accent and no order; one with a bespoke name lands the app in a bucket of one the viewer never opens. - the description is what the card, the row and the store card paint.
The taxonomy is ONE authored list: Store/Catalog/Source/StoreCategories.cs. The store reads it
for a section's accent and order; validate-repos.py parses the SAME file (one entry per line, which
is why the table's shape is load-bearing and pinned by its --self-test) and refuses a package whose
category is not in it — or is in it with different capitalisation, since the literal string is the
key. A satellite repo carries no Store, so there the check degrades to "declare SOME category" and
says so.
🚨 A category name is data that is already written down elsewhere — in every package's
index.json and in every viewer's installed-app records. Renaming one in the taxonomy renames
neither: it makes the old name unknown to the gate and strands tiles in a group no category explains.
A rename is the list, every package that declares the old name, and a heal for the records — all
three, or none.
The store page and the home's filter order categories DIFFERENTLY, on purpose. The storefront
sells courses first (StoreCatalogLayoutAreas.CategoryRank puts Education at 0, then the taxonomy's
order); the filter lists them by everyday use (StoreCategories.Rank alone — nobody launching an app
wants courses first). Two purposes, two orders, one taxonomy.
The categories, 2026-09-15
Roland asked for the home to open on what you do, not on the technology: Administration for the things you set up once (LLM settings, providers, hosting, notification channels, payments, electronic-signature setup) and Daily work for what you open every morning (mail, calendar, chat, the approvals waiting on you). Both landed 2026-09-14; the taxonomy now carries every name the fleet uses, satellites included:
| Category | Packages here |
|---|---|
| Daily work | Mail, Teams, Google, ICloud, Approvals |
| Administration | MyAi, Providers, Hosting, Hosting.Instance, Notifications, Observability, Stripe, Signature |
| Operations | BuildServer, Governance |
| Communication | WhatsApp, iMessage |
| (and) | AI · Productivity · Developer Tools · Maps & Places · Smart Home · Games · Showcase · Platform |
| (satellites) | Education · Insurance · Manufacturing · Social Media · Marketing |
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/description converge to its package | Store/Installer/Source/AppTileRefresh.{Renamed,Described,Refreshed} |
| A tile's GROUP converges to its package's category unless the viewer chose it | Store/Installer/Source/Localizer.WithGroup · MeshSearchView.WithArrangement (the customGroup flag) |
| The category taxonomy — the one authored list | Store/Catalog/Source/StoreCategories.cs · gated by scripts/validate-repos.py → check_package_categories |
| The launcher's filters, views, memory and tile menu | src/MeshWeaver.Blazor.Views/Components/Launcher.cs · MeshSearchView.Sortable.cs |
| A learner's progress rides on the same name | Edu/Module/Source/CourseAppTile |
| The mesh-wide migration | Store/Maintenance → RefreshAppTiles |