Skip to content

Pro rebuild (after core release): finish core's extension points for typed subtypes, edge fields and Pro UI (ADR-082) #3414

Closed
1 of 1 issue completed

Description

@malibio

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 (extends subtypes)

Acceptance criteria

Typed subtypes and edge fields (ADR-082 §2, §5)

  • build_shared_services takes additional NodeBehaviors for extends subtypes of core types. They are registered in each database's NodeBehaviorRegistry, and a behaviour for a type core already defines is rejected at startup.
  • build_shared_services takes 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_services takes 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 collection with its own behaviour (validation, bucketed fields, subtype-aware queries returning it for collection, retype through rebucket_and_validate); an edge field on member_of; and a marked database opening with a supported id.

Frontend contribution kinds (ADR-082 §3.2)

  • nodeTypes: an extension registers a PluginDefinition (lazy node component and TS types) through pluginRegistry.
  • treeItemActions: an action button shown on hover on each collection-tree item in the navigation panel, with props { nodeId, nodeType }. Its when() 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-modal contribution, mirroring the Pro permissions flow.

Tauri (ADR-082 §4)

  • AppExtensions::daemon_profile and AppExtensions::update_source let an app built on core supply its DaemonProfile and UpdateSource. Core uses its own defaults with AppExtensions::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)

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 and onDatabaseActivated. If it cannot, propose the missing hook as an ADR-082 amendment before building it.

Docs

  • architecture/extension-points.md is written in nodespace-docs and linked from architecture/plugin-system.md.

Merged early work

Folds in

#3317, #3320, #3321, #3322, #3308, #3347 (the open remainder of the earlier extension-point sub-issues of #3298).

Dependencies

References

Read these before starting. They are the source of truth; where this issue and an ADR disagree, the ADR wins.

Activity

  1. self-assigned this
    on Oct 5, 2026
  2. mstomar125 commented on Oct 5, 2026

    @mstomar125
    Contributor

    Picking this up, one section at a time, starting with Tauri (ADR-082 §4):

    AppExtensions::update_source is already on main from #3410. This section touches only app-lib's extension module. The other sections (typed subtypes and edge fields, frontend nodeTypes/treeItemActions, versioning, docs) follow as separate PRs and avoid the files Michael's open PRs are in.

    🤖 Generated with Claude Code

  3. mstomar125 commented on Oct 5, 2026

    @mstomar125
    Contributor

    Progress on this umbrella:

    Left for the remaining sections, found while building the frontend kinds:

    1. Docs: amend ADR-082 §3.2. TreeItemActionContribution.when receives { 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 write architecture/extension-points.md.
    2. Typed subtypes: pane-content.svelte and viewerTabsFor match the node type exactly. A collection subtype therefore opens without collection's viewer tabs; the fix is to fall back through isA. This predates Add the nodeTypes and treeItemActions extension contribution kinds (extension API 2.2) #3619.
    3. Host API §3.6: nodes.updateNode is still missing from the nodes facade.

    The typed-subtypes and edge-fields section is held until Michael's schema and skills work in that area settles.

    🤖 Generated with Claude Code

  4. mstomar125 commented on Oct 5, 2026

    @mstomar125
    Contributor

    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:

    • it doesn't make the community daemon's boot open;
    • it adds a gate layer to the Server<L> it passes to build_base_router, which is the router-factory hook in ADR-082 §5's first row, versioned under §8. In tonic, Server::layer wraps Routes, so the gate sees /nodespace.<Service>/<Method> before routing and can answer with a Status.

    The frontend hooks hold nothing, and they don't need to, because nothing in the frontend opens a database. start and an app-shell-modal contribution 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: the DatabaseService.Create RPC.
    • incompatible_database::open_default_or_record_refusal → get_or_open: the boot open. Only nodespaced's main.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/agent are all in test modules. build_shared_services, DatabaseManager::load, the tray, the single-instance lock and the idle reaper open nothing either.

    Open paths

    # Path Where (core main) How the Pro app holds it
    1 Daemon's own startup open packages/daemon/src/main.rs: open_default_database, private to the nodespaced binary, called by serve_headless/serve_grpc before binding The Pro daemon's main doesn't call it until sign-in. BaseServices still needs a service set, and it can be built without a user database: either by hand from the public impl constructors over an in-memory SqliteStore, as core's private build_unrouted_services does, or through the public unrouted_services_if_default_refused. With DbManagerLayer installed, routing is total: every per-database handler goes through the manager, so these placeholders never serve a database.
    2 Any routed RPC (app, CLI, MCP, agent sessions) packages/daemon/src/db_routing.rs: routed_database_services A Pro gate layer ahead of DbManagerLayer, default-deny by path until sign-in. It lets through only NodeService/GetDaemonVersion (the product check: app-lib/src/daemon_setup.rs running_daemon_executable), GetDaemonMemory, SettingsService and the Pro sign-in service. Two rules: refuse, don't park, and don't refuse with FAILED_PRECONDITION plus the x-requires-extension-bin payload. 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 every on_channel_rebuilt hook, 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.
    3 DatabaseService.Create, and the registry RPCs services/database_manager.rs create (creates and opens). Register, SetDefault and Rename write databases.toml, and their replies go through info_for → list(), which is path 4's probe. Same gate
    4 DatabaseService.List, and the tray's database submenu DatabaseManager::list (read-only probe of every closed registered file) Same gate. The submenu feed (spawn_tray_database_sync) lives in nodespaced's main.rs, so the Pro daemon decides when to call TrayController::databases_changed. Refusing List until sign-in is the clean choice, for two reasons. No file is read. And core's databaseStore.load() fails at list_databases, so activeDatabaseId stays null, PaneManager doesn't mount, and onDatabaseActivated doesn't fire.
    5 Tauri-layer startup calls app-lib/src/lib.rs setup task: watcher::spawn (WatchNodes), commands::local_agent::start_token_stream_subscription, data_plane_round_trip (GetNode) No AppExtensions hook 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 skips daemon:data-plane-ready.
    6 Frontend's first activate navigation-sidebar.svelte mount → databaseStore.load(): list_databases → set_active_database → notifyDatabaseActivated → list_conflicts. Also: the sidebar's collections/schemas/chats/queries/plays loads, the root layout's initializeSchemaPluginSystem, the app shell's should_prompt_identity_backfill (GetLocalPerson), the 5s get_stale_root_count poll, and PaneManager tabs set_active_database opens nothing: GrpcClient::set_active_database only re-stamps x-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: start is not awaited, and extension-lifecycle.ts says nothing orders start() against the first onDatabaseActivated. onDatabaseActivated is not an "opened" signal: it fires when core commits its client-side selection, whether or not the daemon opened the database.
    7 Tray pick and relaunch (the only external entry) daemon/src/tray.rs open_ui sets NODESPACE_INITIAL_DATABASE and passes --database <id>. These reach initial_database_id / handle_relaunch → tray:select-database → databaseStore.switchTo Ends in routed RPCs, so paths 2 and 4 hold it, and the Pro daemon controls what the submenu lists. Core registers no OS URL scheme (no deep-link plugin). nodespace:// hrefs are in-app node links inside the active database.
    8 CLI packages/cli: a stateless gRPC client of daemon.sock; --database is resolved through DatabaseService.List Paths 2–4
    9 MCP clients packages/cli/src/commands/mcp.rs: nodespace mcp is a stdio adapter that re-runs the CLI against daemon.sock. The daemon serves no MCP of its own. Path 2
    10 Multi-database registry DatabaseManager: lazy get_or_open and create. The idle reaper only closes. ensure_default_registered and repair_doomed_default write databases.toml, not a database. The Pro daemon chooses when to call ensure_default_registered and spawn_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). With List refused at boot, activeDatabaseId is null, so this is not a no-op. It refreshes the registry, sets routing, reloads the database stores (reloadDatabaseStores), fires onDatabaseActivated and wakes the watcher at once.
      • The Pro package has to supply the id from its own plugin.
      • It skips core's remembered selection and the tray's launch id.
      • It resets the tabs.
      • It doesn't re-run the identity-backfill prompt.
    • A webview reload (window.location.reload()). This re-runs core's whole boot against the open gate, including the remembered selection, the tray's launch id and start. 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 List through instead, the selection commits before sign-in. switchTo to that id then does nothing, so only a reload releases the hold, and WatchNodes reconnects only on its own backoff, up to about 30s.

    Two more effects of deferring opens:

    • Table-shape refusals. ADR-084 §3.1's table-shape refusal now happens at the first open after sign-in, not at daemon boot, so the Pro daemon decides how it surfaces.
    • First launch. On a machine that never onboarded, core's onboarding wizard opens beside the sign-in modal, and its identity write is refused until sign-in. This is a UX ordering issue, not an open.

    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_model and the sidecar extraction inside ensure_daemon_running. If another daemon holds the socket at boot, it serves those first calls before it is evicted, including List and the open of the remembered database. That daemon could be:

    • a leftover per-user community registration (ADR-084 §3.2 leaves other macOS users' per-user registrations in place until the product check runs);
    • a daemon started by hand.

    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

  5. mstomar125 commented on Oct 5, 2026

    @mstomar125
    Contributor

    @malibio a status summary for tomorrow's discussion.

    Merged

    In the merge queue: #3625 (subtype behaviours), then #3626 (edge fields). Extension API goes 2.5, then 2.6.

    Needs your call

    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

  6. mstomar125 commented on Oct 12, 2026

    @mstomar125
    Contributor

    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.

    Bullet PR / commit Code Test
    Subtype NodeBehaviors from build_shared_services, registered per database, core-type behaviour rejected at startup #3625 (253b62171) DaemonExtensions::behavior, check() in packages/daemon/src/services/extensions.rs; called first in build_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_refused
    Edge-field declarations per relationship type, stored and validated in properties.<id>.*, unregistered buckets untouched #3626 (977c3f2e3) 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; core extensions/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_alone
    Supported extension ids replace the empty set #3624 (f17f9f1c0) DaemonExtensions::supported_extension(s), id validation in check() a_database_requiring_a_supported_extension_opens, core_refuses_a_database_requiring_any_extension, an_invalid_extension_id_stops_startup
    Core fixtures: collection subtype (validation, bucketed fields, subtype-aware queries, retype via rebucket_and_validate), member_of edge field, marked database opening #3625, #3626, #3624 packages/core/src/extensions/fixture_tests.rs a_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 above
    nodeTypes via pluginRegistry #3619 (89495255a) lib/plugins/ui-extensions.ts / .svelte.ts tests/plugins/ui-extensions.test.ts (nodeTypes cases)
    treeItemActions on hover, props { nodeId, nodeType }, when(), failure isolation #3619, #3622 lib/components/layout/navigation-sidebar.svelte, ui-extensions.svelte.ts tests/plugins/tree-item-actions-outlet.test.ts, tests/components/navigation-sidebar-tree-item-actions.test.ts
    Fixture extensions cover both kinds; hover action opens an app-shell-modal #3619 tests/fixtures/test-extension/ (test-tree-action.svelte, test-item-modal.svelte, test-throwing-tree-action.svelte) the two tests above
    AppExtensions::daemon_profile / update_source, core defaults with none() #3613 (657c9bf45), #3410 app-lib/src/extensions/mod.rs app-lib/tests/it/extension_daemon_profile_test.rs; extensions/fixture_tests.rs (update_source_falls_back_to_the_builtin_without_extension_state)
    Window capability added at startup; test fails if it names a plugin the app crate does not depend on #3613 app-lib/src/window_capability.rs (add, plugin_crates) window_capability.rs unit tests; src-tauri/src/window_capability_tests.rs (checks plugin_crates() against the app crate's dependencies)
    EXTENSION_API_VERSION in Rust and TS with an agreement test #3409, #3620, then removed by #3657 (closes #3651) none on main, by design superseded by ADR-082 §8 amendment
    Pre-push version-change gate; "Extension API" release-notes heading #3620, then removed by #3657 none on main, by design superseded by ADR-082 §8 amendment (scripts/release.test.ts asserts the heading is absent)
    Sign-in gate check comment of 2026-10-05 (verdict CAN, no hook needed); gap found became #3623, closed (with #3616) n/a n/a
    Docs architecture/extension-points.md linked from plugin-system.md nodespace-docs main file present; plugin-system.md links extension-points.md#node-types; ADR-082 §3.2 and §8 amended n/a
    Reconcile #3409 host API with final ADR-082 #3619, #3622 (nodes.updateNode, isA viewer fallback), #3657 extension-api entries tests/extension-api/extension-api-boundary.test.ts

    Evidence 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions