Goal: no private-extension name (business, <slug>, …) is hardcoded anywhere
in core. Core integrates with extensions only through generic, slug-agnostic seams;
extension-specific behavior lives in the extension.
This is the plan of record for the de-hardcoding sweep. Status checkboxes track progress.
- Core mode — the committed
server/Gemfile+ public-onlyserver/Gemfile.lock. The Gemfile discovers extensions generically (extensions_loader_helper.rbscansextensions/*/extension.json) and bundles all public extensions (those in.gitmodules) regardless of whether they're enabled. Bundling ≠ enabling — enablement is runtime (config/extensions_state.json+ Flipper). - Private mode (renamed from "full mode") —
server/Gemfile.private(wasGemfile.full): core + public + private extensions. Selected explicitly viaBUNDLE_GEMFILE=server/Gemfile.private bundle install(setsPOWERNODE_INCLUDE_PRIVATE_EXTENSIONS=1, re-evals the base Gemfile). Its lockGemfile.private.lockis gitignored. Only for checkouts that actually have the private upstreams on disk.
- Backend:
Powernode::ExtensionRegistry(.loaded?(slug),.slugs,.engine_for(slug),.all,.feature_available?(feature, account:)),Shared::FeatureGateService(.extension_loaded?,.extension_enabled?,.extension_manifest_present?,.loaded_extensions,.core_mode?),Shared::ExtensionStateStore,Shared::ExtensionPaths. - Frontend: build-time
__EXTENSIONS__slug list +featureRegistry(isExtensionLoaded,registerRoutes).
The gap: callers still name specific slugs (business_loaded?,
includes('business')) instead of being slug-driven, and several gates need the
extension to register a capability so core can ask generically.
Mechanical, contained. server/Gemfile.full → Gemfile.private;
Gemfile.full.lock → Gemfile.private.lock (regen); update
/etc/powernode/backend-default.conf BUNDLE_GEMFILE, .gitignore,
extensions_loader_helper.rb comment, .gitea/workflows/extensions-bundle.yml,
scripts/check-lockfile-public-only.sh, scripts/regen-public-lockfile.sh,
docs/contributing/development-setup.md, docs/guides/extensions.md,
CLAUDE.md, CLAUDE.local.md. Keep the POWERNODE_INCLUDE_PRIVATE_EXTENSIONS flag.
Remove the business-named methods; expose generic equivalents:
business_loaded?→ callers useextension_loaded?(slug).business_enabled?→extension_enabled?(slug)(already generic; uses:"#{slug}_mode"Flipper).set_business_enabled!(bool)→set_extension_enabled!(slug, bool).business_feature?/core_feature?→ registry-driven: a feature is "core" iff no loaded extension claims it. AddExtensionRegistry.feature_owner(feature)soavailable?needs no slug literal.billing_enabled?→available?(:billing)(registry decides which extension owns:billing).development_info→ generic{ extensions: loaded_extensions }only.
Replace the business-specific Development tab + PUT /admin_settings/development {business_enabled} with a generic per-slug toggle ({slug, enabled} →
set_extension_enabled!). Frontend lists loaded_extensions and toggles each;
per-extension detail (license/version/feature flags) is supplied by the
extension (registered via featureRegistry/an engine hook), not core. Removes
business_installed/business_enabled payload fields and the business copy.
Phase 3 — Extension-provided presence-gate seams (core + submodules) ☑ (done — implemented as the generic provider+capability seam; see Implementation log)
The ~10 loaded?("business") / :business_mode gates in core
(registrations_controller public-registration gate, subscription_channel,
config_controller registration, mcp_scanner_service, account_scoped,
agent_template, business_aware concern, flipper.rb initializer) must become
capability-driven: the business extension registers the capability (e.g.
public_registration, subscriptions) and core checks
available?(capability) / iterates loaded extensions. Requires business
submodule changes (commit inside the submodule first).
worker_job_service.rbextension-specific market-arms/discovery logic → the owning private extension (pulled via a generic worker hook).knowledge_doc_sync_service.rbEXTENSION_TAG_ROUTESslug map → each extension declares the doc tags it owns (extension.json / registry), core queries generically.- Audit
frontendhardcoded/app/businessand/app/<slug>routes + navextensionSlugentries → register routes/nav from the extension viafeatureRegistryinstead of corenavigation.tsx.
rubocop + rspec + tsc --noEmit; boot in private mode and confirm business loads
and the generic admin Extensions page works; run the AI smoke harness. Add a grep
gate (CI) failing on new private-extension slug literals in core (excluding the
benign tier/plan names enumerated below).
Rate-limit/plan tier literals named business (rate_limiting/tiered_service.rb,
oauth_application.rb, webhook_endpoint.rb, marketplace tier unions); skill
type business_search; "business value"/"businesses" prose; doc/comment examples;
domain scopes like for_domain("<slug>"). The CI grep gate must allowlist these.
- Private extensions (
extensions/private/*) are referenced only in maintainer-local docs; keep their names out of public-facing core per the existing code-scrub. - Phases 3–4 touch the private submodules — follow submodule commit discipline
(commit inside the submodule first; note
extensions/private/*is gitignored in the parent, so there is NO parent pointer to bump for private extensions).
Done so far:
- Phase 1 (committed): registry
feature_owned?, genericcore_feature?,set_extension_enabled!; deletedbusiness_feature?/PowernodeBusiness::Featureshardcode. - Phase 2 (committed): generic admin Extensions panel; the Development tab is core, not business.
- business submodule (committed
91ccbc0):Features.available?now returnsnilfor unowned features (registry contract); declaredpublic_registration+subscriptionscapabilities +business_public_registrationflag.
Findings that change the finish plan:
Shared::FeatureGateService.available?has zero callers — theavailable?/core_feature?/feature_owned?chain is currently dead. The live gates arebusiness_loaded?/loaded?("business")/billing_enabled?.- Presence via
feature_owned?is NOT robust until every extension honors the nil contract. Thesystemextension's features_module returnstruefor ALL features →feature_owned?is always true in full mode. Fixing this one by one across submodules is fragile. - Recommended robust mechanism: add explicit capability declaration to the
registry —
register(slug:, ..., capabilities: [..])+provides?(cap)(any loaded extension declares cap). Core gates then useFeatureGateService.capability_present?(cap) == registry.provides?(cap)for presence (model/code availability) andavailable?(cap)for licensed features. This does NOT depend on features_module nil-contract compliance.
Remaining gate migration (then delete business_loaded?/business_enabled?):
| Call site | New check |
|---|---|
| registrations_controller, config_controller | available?(:public_registration) |
| subscription_channel | capability_present?(:subscriptions) |
| mcp_scanner_service | capability_present?(:mcp_hosting) |
| ai/concerns/account_scoped | capability_present?(:governance) |
| models/ai/agent_template | capability_present?(:marketplace_monetization) |
| models/concerns/business_aware | migrate callers to capability_present?; remove concern |
autonomy/approval_workflow_service (own business_loaded?) |
capability_present?(:governance) |
billing_enabled? (11 callers in entitlements/users) |
keep the name; body → available?(:billing) (add :billing to business); callers unchanged |
| config/initializers/flipper.rb | move business flag registration into the business engine initializer |
Verify in BOTH modes: core (Gemfile) and full (BUNDLE_GEMFILE=Gemfile.private).
Specs to update: usage_limit_service_spec, approval_workflow_service_spec,
auth/registrations_spec, feature_gate_service_spec.
The finish work was implemented as a generic injection seam, not just slug-genericized gates. The bar: a brand-new extension plugs in with zero core edits. Core now integrates with extensions only through these generic, slug-agnostic seams:
| Seam | Core API | Used for |
|---|---|---|
| Capability presence | ExtensionRegistry.register(capabilities:) + provides?(cap) + FeatureGateService.capability_present?(cap) |
presence gates (subscriptions, governance, public_registration) |
| Behavior provider (IoC) | ExtensionRegistry.register(providers: { key: Impl }) + provider(key) (nil ⇒ core default) |
entitlements policy, mcp hosted-server source |
| Model decoration | config.to_prepare + *_decorator.rb class_eval |
Ai::AgentTemplate publisher/marketplace assoc |
| Notifications | ActiveSupport::Notifications |
post-registration subscription provisioning |
| Manifest metadata | extension.json keys (feature_flag, knowledge_doc_tags) |
flags, doc-tag routing |
| Frontend featureRegistry | featureRegistry.register* |
nav items, routes, header widgets |
feature_available?/feature_owned? were hardened to consult only capability-declaring
extensions, permanently defusing the system extension's blanket-true features_module
without touching the system submodule. A spec registers a synthetic extension to prove the
contract (spec/lib/powernode/extension_registry_spec.rb).
Status (this pass):
- Phase 3 done. Registry gained
capabilities:/providers:/provides?/provider; FeatureGateService gainedcapability_present?. Migrated gates: registrations + config (capability_present?(:public_registration)), subscription_channel (capability_present?(:subscriptions)), mcp_scanner (provider(:mcp_hosted_servers)), account_scoped audit + approval_workflow (capability_present?(:governance)), entitlements ×11 + users_controller (provider(:entitlements)), agent_template (business decorator). Deletedbusiness_loaded?/business_enabled?/billing_enabled?and the deadBusinessAwareconcern; removed the business block fromflipper.rb(the business engine now owns its flag enable). Business submodule declarescapabilities: FEATURE_TIERS.keys+providers:(Billing::EntitlementsProvider,Mcp::HostedServerSource) and adds the agent_template decorator. No:billingfeature was needed —provider(:entitlements).present?IS the billing-enabled signal, giving exact parity (core ⇒ unlimited; provider+no-plan ⇒ restrictive) with no license-lapse footgun. - Phase 4 backend done.
worker_job_service.rbbuild_market_discovery_contextis now a generic hook returning{}; the owning private extension overrides it via a WorkerJobService decorator.knowledge_doc_sync_service.rbEXTENSION_TAG_ROUTES/PREFIXconstants replaced by a manifest scan (knowledge_doc_tagsdeclared in each extension'sextension.json). Frontend nav/route decoupling delegated. - Verified: rubocop clean (core + touched private-extension files); core-mode rspec for
every changed file + key consumers (extension_registry, feature_gate, usage_limit,
approval_workflow, registrations, mcp_scanner, knowledge_doc_sync, adaptation_proposer,
users_controller/config/users) all green;
tsc --noEmitclean + frontend suite (6058 tests) green; both-mode boot smokes confirm the runtime seam (core mode: providers nil, unlimited, gates off; private mode: business loaded, providers resolve, decorators applied, capability gates light up). Grep gate + CI workflow added and passing. - Not run (optional follow-ups, not blockers): the no-path full
rspecsuite (runs >70 min on this codebase — unrelated to these changes; the targeted+consumer coverage above is the relevant gate), and the live AI smoke harness (boot smokes already validate the runtime).
The broader extension coupling that this roadmap originally surfaced as out of scope has since been fully relocated into the owning private extension via the generic seams above. That work moved the extension's worker dispatchers/queues and job lifecycle, its circuit breaker, its channels, its skill category, its prompt domain, its intervention categories, and its ephemeral-knowledge patterns out of core. Core now names no private extension and integrates with all of them only through the generic, slug-agnostic seams documented here.