Agentos stores: de-singleton to provider-hosted + JSON-fetched seeds
Context
Operator follow-up on merged PR #14905 (2026-07-10, verbatim, public-safe):
"a store should NEVER use singleton true => if used inside a state provider, we get it anyway. [the inline data: rows] => should be fetching a json file. maybe this is already planned => moving from static to dynamic data."
Two directives: (1) app stores must not be singletons — the state.Provider stores layer is the framework's sharing mechanism; (2) seed/sample data belongs in a fetched JSON file, not inline data: rows.
Duplicate-coverage check against the tree: the roster's static→live move IS already planned — that is #14802 (Brain-side assembler join → live registry rows through FleetCockpit.loadRoster()'s first-wire path, ratified 2026-07-09). What is NOT covered anywhere: the store-topology rule (de-singleton) and the JSON-file seed fetch for the pre-wire sample. This ticket is that gap.
The Problem
apps/agentos currently has two singleton stores, and the second was sibling-lifted from the first — the anti-pattern is propagating exactly the way the app-work gate predicts:
apps/agentos/store/AgentDefinitions.mjs — singleton: true; consumed via direct module-import by FleetSettingsPanel (reads) and Accounts (writes).
apps/agentos/store/FleetRoster.mjs — singleton: true (added by PR #14905, lifted from the AgentDefinitions precedent); consumed via direct import by FleetCockpit, seated onto FleetGrid + HealthBar.
Both also carry inline seed rows (FleetRoster seeds the seven-maintainer sample roster; AgentDefinitions seeds a bridge-pending placeholder row).
Singleton stores are module-global state: they bypass the component tree's scoping, survive view destruction invisibly (PR #14905 already had to add manual destroy() listener un-binding on grid + bar because the store outlives its views), and make test isolation depend on never touching the shared instance.
The Architectural Reality (V-B-A'd this session)
src/state/Provider.mjs natively hosts stores: stores_ config (:148-164), 'stores.<key>' binding resolution (resolveStore, :441), and getStore(key) walking the provider hierarchy (:562-566). A provider at a view root IS the sharing scope — "if used inside a state provider, we get it anyway."
src/data/Store.mjs natively fetches: a url config builds an Xhr-connection pipeline in the browser (load()), with a Node fs/promises.readFile fallback — so a JSON-seeded store stays unit-testable without a web server.
- The
keyProperty store-mirror discipline from PR #14905 (collection.Base defaults keyProperty: 'id', shadowing model-level keys) must survive the refactor — FleetRoster keeps keyProperty: 'agentId' at store grain.
- PR #14905's specs already create isolated per-test stores for grid/card units — only the roster-contract spec (which imports the singleton) and the view wiring need reshaping.
The Fix
FleetRoster: drop singleton: true; move the seven-row sample seed to apps/agentos/resources/data/fleetRoster.json; configure url + autoLoad (keep model, keyProperty: 'agentId', and the honest-sample JSDoc). The engineTag truth pins move with it.
- Cockpit provider hosts the roster:
FleetCockpit declares ONE state.Provider at its root with stores: {fleetRoster: {module: FleetRoster, ...}}; FleetGrid + HealthBar receive it via the provider store binding (bind: {store: 'stores.fleetRoster'}) instead of a module import. loadRoster() reaches the same instance through the grid seam (unchanged consumer contract: first-wire populate, later merge, fail-closed matrix).
AgentDefinitions: same treatment at the shared ancestor — de-singleton, hosted by a Viewport-level provider so Accounts (writer) and FleetSettingsPanel (reader) resolve the same instance up the tree. Whether its single bridge-pending placeholder row moves to JSON or stays inline is the implementer's call (it is a state indicator, not sample data) — the load-bearing part is de-singleton + provider-hosting.
- Specs: reshape the roster-contract describe (currently asserts the singleton import) to the provider shape; keep the isolated per-test stores for unit grain; adapt the cockpit
loadRoster doubles if the store-reach path changes.
- Grep-proof in the PR: zero
singleton: true under apps/agentos/store/**; zero direct store singleton imports in apps/agentos/view/**.
Contract Ledger
| Target surface |
Source of authority |
Proposed behavior |
Fallback / compatibility |
Docs |
Evidence |
AgentOS.store.FleetRoster export |
src/data/Store.mjs + operator directive (PR #14905 follow-up) |
Class export (no singleton); instantiated by the cockpit provider |
Consumers migrate to provider binding in the same PR |
JSDoc |
grep-proof + unit specs |
apps/agentos/resources/data/fleetRoster.json |
Store url/pipeline contract |
Fetched sample seed (seven real maintainers, engineTag truth) |
Fail-closed: fetch failure keeps an empty-but-labelled surface, never a crash |
in-file comment N/A (JSON) → JSDoc on the store |
roster spec + live mount |
Cockpit provider stores.fleetRoster |
src/state/Provider.mjs stores_ contract |
The one shared roster instance for grid + health bar + loadRoster |
Provider hierarchy resolves for any future cockpit descendant |
JSDoc |
wiring specs |
AgentOS.store.AgentDefinitions export |
same |
Class export; Viewport-provider-hosted for Accounts + FleetSettingsPanel |
Same-PR consumer migration |
JSDoc |
settings/accounts specs stay green |
Acceptance Criteria
Out of Scope
- #14802 (the Brain-side assembler join feeding LIVE rows — separate, already signed; this ticket only relocates the pre-wire sample).
- Amending the
AGENTS.md app-work gate row with a "stores are provider-hosted, never singletons" clause — candidate compact addition, but the file sits ~35 bytes under its 24KB cap; decide at implementation whether a paired compression funds it or the rule stays ticket-documented until the mechanical lint (the gate row's stated retirement trigger) absorbs it.
Avoided Traps
- "Keep the singleton because Accounts writes cross-view." The provider at the shared ancestor IS the cross-view mechanism —
getStore() walks up; that is the operator's point.
- Hand-rolled
fetch() for the JSON seed. The Store's native url/pipeline path exists, has a Node fallback for specs, and fires the same load event the views already consume.
- De-singleton without consumer migration in the same PR. A class export with surviving
Neo.create call-sites per-view would fork N instances silently — the provider hosting and the consumer migration are one atomic change.
Decision Record impact
none — aligned-with the established state.Provider stores contract and the #14899 data-path discipline. No ADR created or challenged.
Related
- PR #14905 (origin: operator follow-up on the merge) · #14899 (the Store/Model rebuild this completes) · #14802 (live-data producer, sibling) · parent epic #13015 · #14805 (design-conformance epic, adjacent consumer of the same app).
Live Sweeps
- Live latest-open sweep: checked the latest 20 open issues at 2026-07-10T01:10Z; no equivalent store-topology/JSON-seed ticket found (#14805 is token-consumption conformance; #14807 is the account/config model).
- A2A in-flight claim sweep: latest 30 all-status messages at 2026-07-10T01:10Z; no overlapping
[lane-claim]/[lane-intent] in the herd window.
Origin Session ID: d2fbbdb4-404b-47e1-bbb3-1b9e0330894b
Handoff Retrieval Hints:
query_raw_memories: "agentos store singleton provider-hosted JSON seed fleetRoster de-singleton"
- Anchor: PR #14905 merge-thread operator follow-up;
store-keyproperty-shadows-model discipline carries over.
Agentos stores: de-singleton to provider-hosted + JSON-fetched seeds
Context
Operator follow-up on merged PR #14905 (2026-07-10, verbatim, public-safe):
Two directives: (1) app stores must not be singletons — the
state.Providerstores layer is the framework's sharing mechanism; (2) seed/sample data belongs in a fetched JSON file, not inlinedata:rows.Duplicate-coverage check against the tree: the roster's static→live move IS already planned — that is #14802 (Brain-side assembler join → live registry rows through
FleetCockpit.loadRoster()'s first-wire path, ratified 2026-07-09). What is NOT covered anywhere: the store-topology rule (de-singleton) and the JSON-file seed fetch for the pre-wire sample. This ticket is that gap.The Problem
apps/agentoscurrently has two singleton stores, and the second was sibling-lifted from the first — the anti-pattern is propagating exactly the way the app-work gate predicts:apps/agentos/store/AgentDefinitions.mjs—singleton: true; consumed via direct module-import byFleetSettingsPanel(reads) andAccounts(writes).apps/agentos/store/FleetRoster.mjs—singleton: true(added by PR #14905, lifted from the AgentDefinitions precedent); consumed via direct import byFleetCockpit, seated ontoFleetGrid+HealthBar.Both also carry inline seed rows (
FleetRosterseeds the seven-maintainer sample roster;AgentDefinitionsseeds a bridge-pending placeholder row).Singleton stores are module-global state: they bypass the component tree's scoping, survive view destruction invisibly (PR #14905 already had to add manual
destroy()listener un-binding on grid + bar because the store outlives its views), and make test isolation depend on never touching the shared instance.The Architectural Reality (V-B-A'd this session)
src/state/Provider.mjsnatively hosts stores:stores_config (:148-164),'stores.<key>'binding resolution (resolveStore,:441), andgetStore(key)walking the provider hierarchy (:562-566). A provider at a view root IS the sharing scope — "if used inside a state provider, we get it anyway."src/data/Store.mjsnatively fetches: aurlconfig builds an Xhr-connection pipeline in the browser (load()), with a Nodefs/promises.readFilefallback — so a JSON-seeded store stays unit-testable without a web server.keyPropertystore-mirror discipline from PR #14905 (collection.BasedefaultskeyProperty: 'id', shadowing model-level keys) must survive the refactor —FleetRosterkeepskeyProperty: 'agentId'at store grain.The Fix
FleetRoster: dropsingleton: true; move the seven-row sample seed toapps/agentos/resources/data/fleetRoster.json; configureurl+autoLoad(keepmodel,keyProperty: 'agentId', and the honest-sample JSDoc). The engineTag truth pins move with it.FleetCockpitdeclares ONEstate.Providerat its root withstores: {fleetRoster: {module: FleetRoster, ...}};FleetGrid+HealthBarreceive it via the provider store binding (bind: {store: 'stores.fleetRoster'}) instead of a module import.loadRoster()reaches the same instance through the grid seam (unchanged consumer contract: first-wire populate, later merge, fail-closed matrix).AgentDefinitions: same treatment at the shared ancestor — de-singleton, hosted by a Viewport-level provider soAccounts(writer) andFleetSettingsPanel(reader) resolve the same instance up the tree. Whether its single bridge-pending placeholder row moves to JSON or stays inline is the implementer's call (it is a state indicator, not sample data) — the load-bearing part is de-singleton + provider-hosting.loadRosterdoubles if the store-reach path changes.singleton: trueunderapps/agentos/store/**; zero direct store singleton imports inapps/agentos/view/**.Contract Ledger
AgentOS.store.FleetRosterexportsrc/data/Store.mjs+ operator directive (PR #14905 follow-up)apps/agentos/resources/data/fleetRoster.jsonurl/pipeline contractstores.fleetRostersrc/state/Provider.mjsstores_contractAgentOS.store.AgentDefinitionsexportAcceptance Criteria
singleton: trueunderapps/agentos/store/**; zero direct store-singleton imports inapps/agentos/view/**(grep evidence in the PR).FleetRoster's sample seed lives inapps/agentos/resources/data/fleetRoster.jsonand loads via the store's nativeurlconfig — no inlinedata:rows.state.Providerat the cockpit root hostsstores.fleetRoster;FleetGrid+HealthBarbind it via the provider (destroy-symmetric listeners preserved).AgentDefinitionsis provider-hosted at the Accounts/FleetSettingsPanel shared ancestor; both consumers resolve the same instance.loadRostersemantics preserved exactly (first-wire populate → merge → fail-closed incl. wired-empty); engineTag truth pins preserved; unit CI green.sample rosterlabel.Out of Scope
AGENTS.mdapp-work gate row with a "stores are provider-hosted, never singletons" clause — candidate compact addition, but the file sits ~35 bytes under its 24KB cap; decide at implementation whether a paired compression funds it or the rule stays ticket-documented until the mechanical lint (the gate row's stated retirement trigger) absorbs it.Avoided Traps
getStore()walks up; that is the operator's point.fetch()for the JSON seed. The Store's nativeurl/pipeline path exists, has a Node fallback for specs, and fires the sameloadevent the views already consume.Neo.createcall-sites per-view would fork N instances silently — the provider hosting and the consumer migration are one atomic change.Decision Record impact
none— aligned-with the establishedstate.Providerstores contract and the #14899 data-path discipline. No ADR created or challenged.Related
Live Sweeps
[lane-claim]/[lane-intent]in the herd window.Origin Session ID: d2fbbdb4-404b-47e1-bbb3-1b9e0330894b
Handoff Retrieval Hints:
query_raw_memories: "agentos store singleton provider-hosted JSON seed fleetRoster de-singleton"store-keyproperty-shadows-modeldiscipline carries over.