Repository navigation
Pro rebuild (after core release): finish core's extension points for typed subtypes, edge fields and Pro UI (ADR-082) #3414
Description
Not a core-release blocker. This is ADR-081 step 5: it lands in core releases after the first, and the Pro rebuild in nodespace-sync builds on it. Much of ADR-082 is already on main (the Tauri library/app split, AppExtensions with plugins, daemon-ready tasks and channel-rebuilt hooks, the build helper, run(extensions, context), the virtual module and host API, the contribution contract, settings sections and slots, lifecycle hooks, collectionTreeRoots, the skill-extension input, and the registry extensions map). This issue finishes the rest.
Decisions
- ADR-082: §2 types and edge fields, §3.2 contribution kinds, §4.5 profile and update source, §5 daemon points, §8 versioning, §9 tests
- ADR-083 §2 (supported extension ids), ADR-078 (
extendssubtypes)
Acceptance criteria
Typed subtypes and edge fields (ADR-082 §2, §5)
-
build_shared_servicestakes additionalNodeBehaviors forextendssubtypes of core types. They are registered in each database'sNodeBehaviorRegistry, and a behaviour for a type core already defines is rejected at startup. -
build_shared_servicestakes edge-field declarations per relationship type (extension id, typed fields, optional validator). Extension edge fields are stored and validated in the extension's bucket (properties.<id>.*), and core never reads an unregistered bucket. -
build_shared_servicestakes the supported extension ids, replacing the step-3 guard's fixed empty set. A database that lists a supported id opens. - Core fixtures cover: a subtype of
collectionwith its own behaviour (validation, bucketed fields, subtype-aware queries returning it forcollection, retype throughrebucket_and_validate); an edge field onmember_of; and a marked database opening with a supported id.
Frontend contribution kinds (ADR-082 §3.2)
-
nodeTypes: an extension registers aPluginDefinition(lazy node component and TS types) throughpluginRegistry. -
treeItemActions: an action button shown on hover on each collection-tree item in the navigation panel, with props{ nodeId, nodeType }. Itswhen()decides visibility, and failures are isolated per ADR-082 §3.4. - Fixture extensions cover both kinds. Using a fixture, a hover action opens an
app-shell-modalcontribution, mirroring the Pro permissions flow.
Tauri (ADR-082 §4)
-
AppExtensions::daemon_profileandAppExtensions::update_sourcelet an app built on core supply itsDaemonProfileandUpdateSource. Core uses its own defaults withAppExtensions::none(). - The app library adds core's window capability at startup with
Manager::add_capability. A core test fails if that capability names a plugin core's app crate does not depend on directly.
Versioning (ADR-082 §8)
-
EXTENSION_API_VERSIONexists in Rust and TypeScript, and a test asserts the two agree. (Delivered in Publish the extension host API: services, ui and testing entries, version policy #3409/Fail the push check when the extension API changes without a version bump #3620, then withdrawn by the ADR-082 §8 amendment of 2026-10-05; removed in Remove the extension API version, its snapshot, push check and release-notes section #3657.) - The pre-push gate fails when the host API's generated declarations snapshot, or a Rust extension fixture, changes without a version change. The release-notes template has an "Extension API" heading. (Delivered in Fail the push check when the extension API changes without a version bump #3620, then withdrawn by the same §8 amendment; removed in Remove the extension API version, its snapshot, push check and release-notes section #3657.)
Sign-in gate check
- Confirm that the Pro app can hold every database open until sign-in (ADR-084 §2: "until the user signs in, it opens no database") using existing hooks: the Pro daemon controls its own opens, and the frontend has
start, the chrome modal andonDatabaseActivated. If it cannot, propose the missing hook as an ADR-082 amendment before building it.
Docs
-
architecture/extension-points.mdis written in nodespace-docs and linked fromarchitecture/plugin-system.md.
Merged early work
- Reconcile the extension host API merged in Publish the extension host API: services, ui and testing entries, version policy #3409 with the final ADR-082 (slot ids such as
collaboration.entry, thetreeItemActionsslot, the version policy). Adjust what landed rather than re-adding it.
Folds in
#3317, #3320, #3321, #3322, #3308, #3347 (the open remainder of the earlier extension-point sub-issues of #3298).
Dependencies
- Blocked by [Core release blocker] Strip all Pro code, edition branching and sync/ACL state from core in one pass (ADR-081 steps 0 and 2) #3412 and [Core release blocker] Refuse Pro databases on every surface and add the contact entry point (ADR-081 step 3) #3413, and lands in a core release after the first.
- Blocks NodeSpaceAI/nodespace-sync#518 (the Pro rebuild builds on these extension points).
References
Read these before starting. They are the source of truth; where this issue and an ADR disagree, the ADR wins.
Activity
mstomar125 commentedon Oct 5, 2026
Picking this up, one section at a time, starting with Tauri (ADR-082 §4):
AppExtensions::daemon_profile, reworking parked draft Let an app crate supply its DaemonProfile through AppExtensions #3416 to the profile ADR-082 §4.5 defines (binary_name+service_envonly; noproduct);AppExtensions::update_sourceis already on main from #3410. This section touches onlyapp-lib's extension module. The other sections (typed subtypes and edge fields, frontendnodeTypes/treeItemActions, versioning, docs) follow as separate PRs and avoid the files Michael's open PRs are in.🤖 Generated with Claude Code
Address review: log a tree-item action's failing when() once per item
Address review: keep a failing when() logged once per contribution
mstomar125 commentedon Oct 5, 2026
Progress on this umbrella:
AppExtensions::daemon_profile, window capability, extension API 2.1).nodeTypes,treeItemActions, fixtures, host-API reconcile, extension API 2.2) is in the merge queue.Left for the remaining sections, found while building the frontend kinds:
TreeItemActionContribution.whenreceives{ nodeId, nodeType }, because the permissions example needs a per-collection decision. Add the nodeTypes and treeItemActions extension contribution kinds (extension API 2.2) #3619 implements it that way. Also writearchitecture/extension-points.md.pane-content.svelteandviewerTabsFormatch the node type exactly. Acollectionsubtype therefore opens withoutcollection's viewer tabs; the fix is to fall back throughisA. This predates Add the nodeTypes and treeItemActions extension contribution kinds (extension API 2.2) #3619.nodes.updateNodeis still missing from thenodesfacade.The typed-subtypes and edge-fields section is held until Michael's schema and skills work in that area settles.
🤖 Generated with Claude Code
Add the nodeTypes and treeItemActions extension contribution kinds
Bump EXTENSION_API_VERSION to 2.2 for the nodeTypes and treeItemActio…
Address review: log a tree-item action's failing when() once per item
Address review: keep a failing when() logged once per contribution
Add the nodeTypes and treeItemActions extension contribution kinds (e…
Fail the push check when the extension API changes without a version …
Fail the push check when the extension API changes without a version …
Fail the push check when the extension API changes without a version …
mstomar125 commentedon Oct 5, 2026
Sign-in gate check (ADR-084 §2): can the Pro app hold every database open until sign-in?
Traced on core
origin/main@89495255. A separate review pass tried to refute this and could not find an open path without a hook. Its corrections are folded in below.Verdict: CAN. Every database open happens inside the daemon process, and the Pro daemon is its own binary with its own
main(ADR-084 §3.3, ADR-082 §4.5). With existing hooks it holds every open in two ways:Server<L>it passes tobuild_base_router, which is the router-factory hook in ADR-082 §5's first row, versioned under §8. In tonic,Server::layerwrapsRoutes, so the gate sees/nodespace.<Service>/<Method>before routing and can answer with aStatus.The frontend hooks hold nothing, and they don't need to, because nothing in the frontend opens a database.
startand anapp-shell-modalcontribution show the sign-in prompt. No ADR-082 amendment is needed. One limitation, the boot window, is stated at the end.Where a database is actually opened
The daemon library has these production open sites, plus one read-only probe:
db_routing::routed_database_services→DatabaseManager::get_or_open: every routed RPC. A request with no header goes to the registry default.DatabaseManager::create→build_database_services: theDatabaseService.CreateRPC.incompatible_database::open_default_or_record_refusal→get_or_open: the boot open. Onlynodespaced'smain.rs(open_default_database) calls it.DatabaseManager::list→read_required_extensions: a read-only probe (mode=ro/immutable=1) of each closed registered file.Nothing outside the daemon opens a database. The Tauri library has no core or libsql dependency. The CLI is gRPC-only and never spawns a daemon. The skill installer and the frontend open nothing. The direct opens in
packages/agentare all in test modules.build_shared_services,DatabaseManager::load, the tray, the single-instance lock and the idle reaper open nothing either.Open paths
main)packages/daemon/src/main.rs:open_default_database, private to thenodespacedbinary, called byserve_headless/serve_grpcbefore bindingmaindoesn't call it until sign-in.BaseServicesstill needs a service set, and it can be built without a user database: either by hand from the public impl constructors over an in-memorySqliteStore, as core's privatebuild_unrouted_servicesdoes, or through the publicunrouted_services_if_default_refused. WithDbManagerLayerinstalled, routing is total: every per-database handler goes through the manager, so these placeholders never serve a database.packages/daemon/src/db_routing.rs:routed_database_servicesDbManagerLayer, default-deny by path until sign-in. It lets through onlyNodeService/GetDaemonVersion(the product check:app-lib/src/daemon_setup.rsrunning_daemon_executable),GetDaemonMemory,SettingsServiceand the Pro sign-in service. Two rules: refuse, don't park, and don't refuse withFAILED_PRECONDITIONplus thex-requires-extension-binpayload. A refused probe is safe:probe_and_recover(app-lib/src/commands/nodes.rs) treats any completed status as a live channel. A request that waits past 5s triggers channel recovery and runs everyon_channel_rebuilthook, and core's 15s poll (services/daemon-status.ts) repeats that. The refusal payload makes core show the Pro-database refusal. Core's daemon health is a socket connect, so refusals never show the daemon as down.DatabaseService.Create, and the registry RPCsservices/database_manager.rscreate(creates and opens).Register,SetDefaultandRenamewritedatabases.toml, and their replies go throughinfo_for→list(), which is path 4's probe.DatabaseService.List, and the tray's database submenuDatabaseManager::list(read-only probe of every closed registered file)spawn_tray_database_sync) lives innodespaced'smain.rs, so the Pro daemon decides when to callTrayController::databases_changed. RefusingListuntil sign-in is the clean choice, for two reasons. No file is read. And core'sdatabaseStore.load()fails atlist_databases, soactiveDatabaseIdstaysnull,PaneManagerdoesn't mount, andonDatabaseActivateddoesn't fire.app-lib/src/lib.rssetup task:watcher::spawn(WatchNodes),commands::local_agent::start_token_stream_subscription,data_plane_round_trip(GetNode)AppExtensionshook suppresses these calls. They only reach the daemon, so path 2 holds them. The watcher retries on a 1s→30s backoff and the token stream every 2s. A refused data-plane probe only skipsdaemon:data-plane-ready.navigation-sidebar.sveltemount →databaseStore.load():list_databases→set_active_database→notifyDatabaseActivated→list_conflicts. Also: the sidebar's collections/schemas/chats/queries/plays loads, the root layout'sinitializeSchemaPluginSystem, the app shell'sshould_prompt_identity_backfill(GetLocalPerson), the 5sget_stale_root_countpoll, andPaneManagertabsset_active_databaseopens nothing:GrpcClient::set_active_databaseonly re-stampsx-ns-database-id. The open happens on the next routed RPC, so paths 2 and 4 hold it. No frontend hook could hold these loads:startis not awaited, andextension-lifecycle.tssays nothing ordersstart()against the firstonDatabaseActivated.onDatabaseActivatedis not an "opened" signal: it fires when core commits its client-side selection, whether or not the daemon opened the database.daemon/src/tray.rsopen_uisetsNODESPACE_INITIAL_DATABASEand passes--database <id>. These reachinitial_database_id/handle_relaunch→tray:select-database→databaseStore.switchTonodespace://hrefs are in-app node links inside the active database.packages/cli: a stateless gRPC client ofdaemon.sock;--databaseis resolved throughDatabaseService.Listpackages/cli/src/commands/mcp.rs:nodespace mcpis a stdio adapter that re-runs the CLI againstdaemon.sock. The daemon serves no MCP of its own.DatabaseManager: lazyget_or_openandcreate. The idle reaper only closes.ensure_default_registeredandrepair_doomed_defaultwritedatabases.toml, not a database.ensure_default_registeredandspawn_idle_reaper. Lazy opens go through path 2.Releasing the hold after sign-in
This is not an open path, but the Pro rebuild needs it. Core does not retry its failed boot loads by itself: it retries only on a daemon reconnect, which its socket-level check never reports while the Pro daemon keeps running. The host API offers two ways to release:
databases.switchTo(id). WithListrefused at boot,activeDatabaseIdisnull, so this is not a no-op. It refreshes the registry, sets routing, reloads the database stores (reloadDatabaseStores), firesonDatabaseActivatedand wakes the watcher at once.window.location.reload()). This re-runs core's whole boot against the open gate, including the remembered selection, the tray's launch id andstart. It is the more complete option.commands/database.rs(initial_database_id) notes that nothing reloads the webview today, and that a reload re-applies the tray's launch pick. That is the right behaviour here.If the gate lets
Listthrough instead, the selection commits before sign-in.switchToto that id then does nothing, so only a reload releases the hold, andWatchNodesreconnects only on its own backoff, up to about 30s.Two more effects of deferring opens:
Limitation: the boot window
The hold assumes the Pro daemon is the one serving the socket when the app starts. The sidebar's first calls ride the lazy channel at once. The product check runs later, after
provision_bundled_modeland the sidecar extraction insideensure_daemon_running. If another daemon holds the socket at boot, it serves those first calls before it is evicted, includingListand the open of the remembered database. That daemon could be:Those opens are made by core's own daemon, which refuses bound databases, so no Pro state is written. The fix is not an extension hook: core's app would have to hold its data-plane calls until the product check passes. That change is generic, and it would also stop core's app from using a leftover Pro daemon before eviction. Filing it on core is the suggested follow-up.
🤖 Generated with Claude Code
mstomar125 commentedon Oct 5, 2026
@malibio a status summary for tomorrow's discussion.
Merged
daemon_profile, window capability.nodeTypes,treeItemActions.nodes.updateNode,isAviewer and tab fallback.architecture/extension-points.md.In the merge queue: #3625 (subtype behaviours), then #3626 (edge fields). Extension API goes 2.5, then 2.6.
Needs your call
when(item)amendment.pro_collectionversuspro-collection. ADR-083 needs the same fix.Follow-ups in progress: #3627 (embedding aggregation uses the database registry), #3616 and #3623. The last two have docs-first proposals held for discussion.
🤖 Generated with Claude Code
mstomar125 commentedon Oct 12, 2026
Verification against origin/main at
2bf95fea6(v0.3.5 bump). Every bullet is met; the two Versioning bullets were delivered and then deliberately withdrawn by the ADR-082 §8 amendment of 2026-10-05 (no extension API version; changes reach the consuming build through its core pin), so they are ticked as superseded. Where the issue and an ADR disagree, the ADR wins.NodeBehaviors frombuild_shared_services, registered per database, core-type behaviour rejected at startup253b62171)DaemonExtensions::behavior,check()inpackages/daemon/src/services/extensions.rs; called first inbuild_shared_services(services/assembly.rs)packages/daemon/tests/it/extension_points_fixture.rs:a_subtype_behaviour_is_enforced_in_every_database,a_behaviour_for_a_core_type_stops_startup; core:a_node_service_with_a_behaviour_for_a_core_type_is_refusedproperties.<id>.*, unregistered buckets untouched977c3f2e3)DaemonExtensions::edge_fields,DataExtensions(packages/core/src/extensions)extension_points_fixture.rs:an_edge_field_is_validated_in_every_database,an_edge_field_declaration_on_has_child_stops_startup; coreextensions/fixture_tests.rs:an_edge_field_on_member_of_is_stored_in_the_extension_bucket,an_invalid_bucket_is_refused_on_create/_in_a_transaction/_on_update_and_on_an_existing_edge,a_bucket_nobody_registered_is_left_alonef17f9f1c0)DaemonExtensions::supported_extension(s), id validation incheck()a_database_requiring_a_supported_extension_opens,core_refuses_a_database_requiring_any_extension,an_invalid_extension_id_stops_startuprebucket_and_validate),member_ofedge field, marked database openingpackages/core/src/extensions/fixture_tests.rsa_subtype_node_is_validated_by_its_behaviour_and_its_base,the_subtypes_fields_are_stored_in_its_own_bucket,queries_for_collection_return_the_subtype,a_collection_retyped_to_the_subtype_is_rebucketed_and_validated,a_subtype_without_its_schema_is_an_unknown_type, plus the daemon tests abovenodeTypesviapluginRegistry89495255a)lib/plugins/ui-extensions.ts/.svelte.tstests/plugins/ui-extensions.test.ts(nodeTypes cases)treeItemActionson hover, props{ nodeId, nodeType },when(), failure isolationlib/components/layout/navigation-sidebar.svelte,ui-extensions.svelte.tstests/plugins/tree-item-actions-outlet.test.ts,tests/components/navigation-sidebar-tree-item-actions.test.tsapp-shell-modaltests/fixtures/test-extension/(test-tree-action.svelte,test-item-modal.svelte,test-throwing-tree-action.svelte)AppExtensions::daemon_profile/update_source, core defaults withnone()657c9bf45), #3410app-lib/src/extensions/mod.rsapp-lib/tests/it/extension_daemon_profile_test.rs;extensions/fixture_tests.rs(update_source_falls_back_to_the_builtin_without_extension_state)app-lib/src/window_capability.rs(add,plugin_crates)window_capability.rsunit tests;src-tauri/src/window_capability_tests.rs(checksplugin_crates()against the app crate's dependencies)EXTENSION_API_VERSIONin Rust and TS with an agreement testscripts/release.test.tsasserts the heading is absent)architecture/extension-points.mdlinked fromplugin-system.mdplugin-system.mdlinksextension-points.md#node-types; ADR-082 §3.2 and §8 amendednodes.updateNode,isAviewer fallback), #3657tests/extension-api/extension-api-boundary.test.tsEvidence that the subtype mechanism is used for real: #3745 (
35eedd562) adds the issue and software-project core subtypes on top of it.Closing as completed.
🤖 Generated with Claude Code