Skip to content

Seed the default apps once per user, not when the grid looks empty - #2149

Open
rbuergi wants to merge 4 commits into
mainfrom
fix/seed-defaults-at-logon
Open

Seed the default apps once per user, not when the grid looks empty#2149
rbuergi wants to merge 4 commits into
mainfrom
fix/seed-defaults-at-logon

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The home seeded the platform defaults (Store, Doc, Threads) from the render path, whenever the viewer's app grid came back EMPTY. Emptiness was standing in for "this user has not been set up yet" — a proxy that was only ever valid while nothing else could write an app record.

Why it breaks now

The Store starts writing an InstalledApp record at install time in MeshWeaver.Plugins#618, merging today. So:

A user who acquires a package before first opening their home arrives with a non-empty grid → the seeding never fires → they lose the defaults permanently.

Including the Store tile itself, which is the one the seeding exists to guarantee: a user without it has no way to reach the Store at all. Nothing errors and nothing logs — they simply get a poorer home than everyone else, forever. It is only reachable for users created after #618 lands, which is why this wants to be close behind it rather than after a gap.

The fix

Seeding is a run-once logon action (SeedDefaultAppsLogonAction), on the framework that landed in #2142. That says what the proxy was reaching for: once per user because the ledger says it has run, not because their grid looked a particular way on one render.

It also fixes the same defect in the other direction, which nobody had reported: under the old trigger, a user who deleted every tile had them silently restored on the next render. Run-once means removed stays removed.

Two properties, both deliberate and both load-bearing next to the Store's writer:

  • Create-if-absent, never overwrite. Each record is created independently and already exists is a benign no-op — so this cannot clobber a record the Store wrote for the same app (with its stamped name/icon), and a logon racing an install cannot produce a duplicate.
  • Idempotent on its own. The framework's ledger is atomic with the user's profile, not with these node writes, so the action is at-least-once by construction and has to survive re-running. It does.

Order = -100, ahead of the pin migration and icon adoption, since both operate on records. Not required for correctness — each is independently idempotent — it just avoids a first logon that heals nothing and then seeds.

Which apps get seeded still comes from Admin/HomeConfig, so it stays deployment-specific (memex and systemorph.com do not ship the same apps). Only "a new user must end up with the declared set" is framework behaviour. A portal that declares an empty set seeds nothing rather than falling back to the shipped list.

Removed with it

ObserveAppRecords — it existed only to evaluate the emptiness proxy. The home now issues one fewer query per render, and the render path performs no app writes at all, with no exception. The class doc used to carve out that one exception; it no longer needs to.

Tests

SeedDefaultAppsLogonActionTest pins the trigger rather than the payload, because the trigger is what was wrong: run-once mode, a stable ledger key (a rename re-runs it for every existing user, which for this action means re-creating records people deliberately deleted), ordering ahead of the record-consuming actions, the set coming from config, an empty declaration seeding nothing, and every seeded record landing inside the user's own RLS-scoped namespace.

Full MeshWeaver.Graph.Test: 1577/1577. -c Release -warnaserror clean on Graph and its test project.

Found while reviewing the Plugins-side install work with that session — it is their change that makes this reachable, and neither half is wrong on its own.

The home seeded the platform defaults (Store, Doc, Threads) from the render path
whenever the viewer's app grid came back EMPTY. Emptiness was standing in for
"this user has not been set up yet", and that proxy was only ever valid while
nothing else could write an app record.

The Store now writes one at install time (MeshWeaver.Plugins#618, merging today).
So a user who acquires a package BEFORE first opening their home arrives with a
non-empty grid, the seeding never fires, and they lose the defaults permanently
— including the Store tile, which is the one the seeding exists to guarantee: a
user without it has no way to reach the Store at all. Nothing errors; they just
get a poorer home than everyone else, forever.

It is a run-once logon action now, which says what the proxy was reaching for:
once per user because the LEDGER says it has run, not because their grid happened
to look a particular way on one render.

That also fixes the same defect in the other direction, which nobody had
reported: under the old trigger a user who deleted every tile had them silently
restored on the next render. Run-once means removed stays removed.

Two properties, both deliberate:

- Create-if-absent, never overwrite. Each record is created independently and
  "already exists" is benign, so this cannot clobber a record the Store wrote for
  the same app, and a logon racing an install cannot duplicate one.
- Idempotent on its own, because the framework's ledger is atomic with the user's
  PROFILE and not with these node writes. The action is therefore at-least-once
  and has to survive re-running.

WHICH apps get seeded still comes from Admin/HomeConfig, so it stays
deployment-specific; only "a new user must end up with the declared set" is
framework behaviour.

Removed with it: ObserveAppRecords, which existed ONLY to evaluate the emptiness
proxy. The home now issues one fewer query per render, and the render path
performs no app writes at all — with no exception, which the class doc now says.

Full MeshWeaver.Graph.Test: 1577/1577. Release -warnaserror clean.

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

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

Moves default app seeding (Store/Doc/Threads) off the home render path and into a run-once logon action, so users always receive the deployment-declared starting tiles even if their app grid is non-empty on first visit.

Changes:

  • Removed the empty-grid bootstrap write path from UserActivityLayoutAreas and deleted the supporting query helper.
  • Added SeedDefaultAppsLogonAction and registered it as a built-in logon action.
  • Added tests and a “What’s New” entry documenting the behavioral change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.Graph.Test/SeedDefaultAppsLogonActionTest.cs Adds unit tests pinning the logon-action trigger semantics and config-driven seeded set.
src/MeshWeaver.Graph/UserActivityLayoutAreas.cs Removes render-time default seeding and the app-record emptiness probe.
src/MeshWeaver.Graph/Logon/SeedDefaultAppsLogonAction.cs Introduces the run-once logon action that seeds default app records.
src/MeshWeaver.Graph/Configuration/LogonActionNodeType.cs Registers the new built-in logon action in DI.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-24-seed-defaults-at-logon.md Documents the user-visible behavior change.

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

Comment thread src/MeshWeaver.Graph/Logon/SeedDefaultAppsLogonAction.cs
Comment thread src/MeshWeaver.Graph/Logon/SeedDefaultAppsLogonAction.cs
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 3)

    9 files  ±0      9 suites  ±0   4m 58s ⏱️ +3s
1 680 tests ±0  1 675 ✅ ±0  5 💤 ±0  0 ❌ ±0 
2 185 runs  +2  2 180 ✅ +2  5 💤 ±0  0 ❌ ±0 

Results for commit 20bf05d. ± Comparison against base commit 5cad7dd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 0)

1 287 tests  +25   1 287 ✅ +25   17m 43s ⏱️ + 3m 50s
    9 suites ± 0       0 💤 ± 0 
    9 files   ± 0       0 ❌ ± 0 

Results for commit 20bf05d. ± Comparison against base commit 5cad7dd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 5)

   10 files  ±0     10 suites  ±0   6m 46s ⏱️ -10s
1 254 tests ±0  1 253 ✅ ±0  1 💤 ±0  0 ❌ ±0 
1 255 runs  ±0  1 254 ✅ ±0  1 💤 ±0  0 ❌ ±0 

Results for commit 20bf05d. ± Comparison against base commit 5cad7dd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 4)

2 140 tests  ±0   1 841 ✅ ±0   8m 23s ⏱️ -7s
    9 suites ±0     299 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit 20bf05d. ± Comparison against base commit 5cad7dd.

♻️ This comment has been updated with latest results.

@github-actions

Copy link
Copy Markdown

Test Results (shard 2)

3 329 tests  +6   3 329 ✅ +6   8m 33s ⏱️ -13s
    8 suites ±0       0 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit 7a085e5. ± Comparison against base commit 5cad7dd.

@github-actions

Copy link
Copy Markdown

Test Results (shard 1)

1 769 tests  ±0   1 769 ✅ ±0   9m 5s ⏱️ +10s
   10 suites ±0       0 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit 7a085e5. ± Comparison against base commit 5cad7dd.

@github-actions

Copy link
Copy Markdown

Test Results

    55 files  ±0      55 suites  ±0   53m 29s ⏱️ + 1m 31s
11 434 tests +6  11 129 ✅ +6  305 💤 ±0  0 ❌ ±0 
11 939 runs  +7  11 634 ✅ +7  305 💤 ±0  0 ❌ ±0 

Results for commit 7a085e5. ± Comparison against base commit 5cad7dd.

rbuergi and others added 3 commits August 24, 2026 20:10
Both correct, both mine.

1. My Skip(1) reintroduced exactly the failure this PR fixes. Observe() is
   StartWith(Defaults) + DistinctUntilChanged, so a portal with NO materialized
   Admin/HomeConfig node — or one whose config equals the shipped defaults —
   emits exactly ONCE. Skipping that emission to "avoid acting on the
   placeholder" waits forever on precisely the fresh deployment this action
   exists to serve: it times out and seeds nothing, including the Store tile.
   I removed one way to lose the defaults and added another.

   Now: take up to two emissions, stop at a 2 s settle bound either way, use
   whichever arrived last. One emission ⇒ the shipped defaults once the bound
   elapses; two ⇒ the configured set as soon as the query answers. Always
   terminates, and still never seeds the shipped set at a portal that declared
   its own.

2. The create helper swallowed EVERY failure. Combined with RunOnce that is the
   worse of the two: the runner commits the ledger entry on completion, so a
   transient fault — access denied, a missing parent, a storage blip — marked the
   action permanently done for that user while leaving them short a tile, with
   nothing that ever revisits it. Only "already exists" is tolerated now (that
   one IS success: it means the Store or a concurrent logon got there first);
   everything else propagates, the ledger stays unwritten, and the next logon
   retries. Free, because every step is idempotent.

Tests pin both halves of the emission behaviour — the single-emission case that
would have hung, and the two-emission case where the configured set must win.

62 home/seeding tests green; -c Release -warnaserror clean including the test
project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Maintainer feedback via the Plugins session, with the correlation already done:
every small tile carried width/height on the icon's root tag, every filling one
was viewBox-only. That reads as an authoring problem. It is not — or not only.

A node icon is injected as a raw MarkupString, so Blazor's CSS isolation never
stamps its scope attribute onto that <svg>. `.mesh-search-icon-box svg` was
unscoped, so it matched NOTHING, and an authored width="24" rendered at 24px
inside a 64px tile with no rule to override it. The row icon was never affected
because its rule always had ::deep — which is exactly why the symptom correlated
with authored dimensions and looked like the icons' fault.

Two independent guards now, because either alone would have hidden this:

- ::deep on the icon-box rule. The real fix: the stylesheet reaches the markup.
- SizeInlineSvg on both icon paths, forcing the size onto the element itself.
  Duplicate attributes resolve first-wins, so the injected style beats both the
  authored attributes and any authored inline style. This is what stops the next
  authored icon with a fixed size from silently regressing the grid.

Threads gets real artwork while I am here — a full-bleed gradient rather than a
grey glyph that read as unfinished beside the Store's tile. Built to the three
constraints that matter and are easy to miss: no root width/height (the bug
above), attribute styling only (React Native renders neither <style> nor
class-driven fills, so a class-based icon is invisible on the phone and fine on
the web), and a namespaced gradient id (several inline SVGs share one document,
and a duplicate id means the first definition wins for everybody).

AppIconRenderingTest pins those three rules plus the first-wins ordering, so the
next icon has something to fail against. HomeTabsTest now asserts the Threads
icon by SHAPE, so the artwork can be redrawn without editing a test.

Full MeshWeaver.Graph.Test: 1584/1584. -c Release -warnaserror clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Changing the Threads icon in the seed only reaches users who have not been seeded
yet. Everyone already seeded keeps the grey chat.svg forever, because nothing
revisits a record once it exists — so the redraw shipped to new accounts and
silently skipped every existing one. That is a half-finished change and this is
its other half; the Plugins session hit the same edge from the Store side and
flagged that the default records are structurally out of ITS reach.

🚨 The rule is deliberately narrow: converge a record only when it wears an icon
CORE ITSELF shipped and has since retired (AppIconAdoption.SupersededDefaultIcons,
an explicit historical list). NOT "anything that differs from the current seed",
which was the obvious formulation and is wrong twice over — it would overwrite an
icon a VIEWER chose, which is theirs, and it would fight the Store, which
converges the icons of records IT owns (MeshWeaver.Plugins#624). Two writers on
one field with overlapping conditions is how a tile starts flickering between two
answers on alternate logons.

The list is enumerable precisely because it is HISTORY: we know what we shipped.
Add a line when a default's artwork changes; a value that never shipped does not
belong there.

EveryLogon, not run-once, for the reason the action exists: a run-once refresh
would converge whatever was stale on the day it ran, record itself as done, and
strand every LATER redraw — the same bug one level up. Steady state is one query
matching nothing.

Disjoint from the icon adoption by construction: that one fills a record with NO
icon, this one moves a record OFF a retired value, and the tests assert neither
matches the other's case so they cannot race on the field. The write re-checks
the condition inside the update, so a Store or viewer choice landing between the
read and the write wins.

Full MeshWeaver.Graph.Test: 1590/1590. -c Release -warnaserror clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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