Skip to content

DX hardening: runtime safety, routing, and multi-tenancy opt-in - #18

Merged
antosubash merged 14 commits into
mainfrom
claude/review-framework-dx-FtWsy
Apr 14, 2026
Merged

DX hardening: runtime safety, routing, and multi-tenancy opt-in#18
antosubash merged 14 commits into
mainfrom
claude/review-framework-dx-FtWsy

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Implements the design decisions from docs/plans/2026-04-14-dx-hardening-design.md to close deferred review items and improve developer experience and production safety.

Summary

This PR hardens the framework across nine areas: runtime safety (strict module discovery, auto-commit only on writes), multi-tenancy opt-in, routing fixes, and documentation. Each change is independently committable and preserves backward compatibility where possible (strict mode defaults to off in dev, multi-tenancy is opt-in).

Key changes

Runtime safety:

  • discover_modules(strict=...) now fails loudly in production on bad modules (import errors, missing meta, wrong base class) while remaining lenient in dev
  • ModuleBase.meta validation moved to discovery time with clear error messages
  • get_db now commits only when the session has pending writes; read-only requests rollback instead (cheaper, cleaner profiling)

Multi-tenancy:

  • TenantMiddleware is now opt-in via Settings.multi_tenant (defaults to False)
  • Settings.tenant_header controls header-based tenant resolution (empty string disables it)

Routing:

  • DashboardModule.view_prefix changed from "" to "/dashboard" (honest routing)
  • Public landing page moved from dashboard module to host-level host/routes.py at /
  • Landing.tsx moved to host/client_app/pages/ (co-located with host routes)

Nits & docs:

  • _PROJECT_ROOT now respects SM_PROJECT_ROOT env var (set by host/main.py) for wheel deployments
  • all_module_bases deduped via keyed dict to prevent unbounded growth on repeated imports
  • print_diagnostics writes to stderr instead of stdout
  • modules/products/products/validation.ts moved to modules/products/products/pages/validation.ts (co-located with consumers)
  • Added docs/framework-conventions.md (reference for module authors)
  • Added docs/plans/2026-04-14-dx-hardening-design.md (design rationale)
  • Added docs/plans/2026-04-14-dx-hardening.md (implementation plan)
  • Expanded README.md with quickstart, project layout, and common commands
  • Added framework/core/simple_module_core/__main__.py for CLI diagnostics (python -m simple_module_core)

Test coverage:

  • Added tests for strict/non-strict discovery modes, missing meta, load failures
  • Added tests for get_db commit/rollback behavior
  • Added tests for _resolve_project_root env var handling
  • Added test for all_module_bases deduplication

Notable implementation details

  • discover_modules(strict=False) is the default, preserving dev ergonomics; app_builder.create_app passes strict=not settings.is_development
  • get_db checks session.new / session.dirty / session.deleted before committing; a pure-read session exits via rollback
  • Module-level __getattr__ (PEP 562) exposes all_module_bases from the deduped dict, preserving the import path for host/migrations/env.py and conftest.py
  • TenantMiddleware is only installed when settings.multi_tenant=True; existing tests keep it on for backward compatibility
  • host/main.py sets SM_PROJECT_ROOT before importing the hosting layer, so app_builder can find static/template assets in wheel deployments

https://claude.ai/code/session_01C9L3h13CsmwiQD7yetZuRA

claude added 14 commits April 14, 2026 16:35
- make doctor: add simple_module_core.__main__ so the target actually runs
- .env.example: use SM_AUTH_* prefix so AuthSettings binds the values
- pages.ts: PascalCase snake_case module dirs so inertia keys match backend
- create_module_base: auto-detect provider from SM_DATABASE_URL so models
  work on both SQLite and PostgreSQL without code changes
- permissions: drop example module names from DEFAULT_ROLE_PERMISSIONS
  so the framework no longer couples to plugin permissions
- EventBus: dispatch across MRO (base-class subscriptions see subclass
  events); switch publish_nowait to get_running_loop
- app_builder: exact-match app.state key check in SM012 (prev. substring
  match caused false positives/negatives for prefix-overlapping modules)
- diagnostics: split reused SM009/SM010 codes
  (SM009=coupling, SM010=revision mismatch, SM011=table coverage,
   SM012=missing settings registration)
- PermissionRegistry.has: O(n) lookup without allocating a sorted set
- new_module scaffold: generate Browse/Create/Edit .tsx stubs so a fresh
  module boots without SM004 phantom-render warnings
Design + implementation plan for the nine deferred items flagged in the
2026-04-14 framework review: get_db commit semantics, strict module
discovery, meta enforcement, opt-in tenant middleware, dashboard routing,
docs, and assorted nits.
…Meta

In strict mode (production, triggered by settings.is_development=False):

- Missing/invalid `meta` → InvalidModuleError at boot
- Not a ModuleBase subclass → InvalidModuleError
- Entry-point load failure → InvalidModuleError
- Instantiation failure → InvalidModuleError

In non-strict mode (dev), behaviour is unchanged: log + skip the bad
module. `host/migrations/env.py` and `conftest.py` both stay on the
lenient default because they're dev-only callers.
…ng page

- DashboardModule.meta.view_prefix: "" -> "/dashboard"
- Dashboard endpoints no longer define a "/" route
- modules/dashboard/pages/Landing.tsx -> host/client_app/pages/Landing.tsx
  (Inertia component key: "Landing" — host-level keys are just the filename)
- New host/routes.py mounts the public landing page at "/"
- host/main.py includes the host router after create_app() returns
Two settings, both defaulting to safe-for-single-tenant:

- multi_tenant (default False) gates whether the middleware is added
  to the pipeline at all.
- tenant_header (default "") controls the header fallback. Empty string
  disables the header source entirely — production deployments should
  lean on auth-token claims.

TenantMiddleware.__init__ now takes header=None; callers that want
header-based resolution pass the header name explicitly.

Test fixture keeps multi_tenant=True so the existing header-driven
tests continue to work; one new test asserts the middleware is absent
when the setting is off.
Read-only endpoints no longer issue a round-trip COMMIT on exit — they
release the implicit transaction via rollback, which is cheaper and
keeps the session out of pg_stat_statements as a writer.

Decision on "did we write?" comes from two sources:

1. An ``after_flush`` listener stamps ``session.info['has_writes'] = True``
   — flush empties ``session.new/dirty/deleted`` so the live collections
   alone aren't enough (services flush + refresh inside their create()
   path, leaving the session visually clean at dep-exit time).
2. Fallback check on ``session.new/dirty/deleted`` catches the
   add-without-flush case.

Log events:
- db.session.commit     (INFO) — pending writes committed
- db.session.read_only  (DEBUG) — no writes, rolled back
- db.session.rollback   (WARNING) — exception raised, rolled back
- deps.py: hoist duration_ms out of three branches; consolidate commit
  vs. read-only log call; import SESSION_HAS_WRITES_KEY from listeners
- listeners.py: extract SESSION_HAS_WRITES_KEY constant so the session
  flag isn't a stringly-typed cross-module contract
- discovery.py: factor the 4x-repeated strict/log pattern into a nested
  `fail()` helper
- test_core.py: hoist per-method imports to module level; simplify
  _FakeEntryPoint to accept a class directly (no more lambda wrappers)
- test_app.py: replace the two filesystem-dependent fallback tests with
  one that doesn't rely on the workspace layout
- test_db.py: rewrite TestGetDbLogging to use the db_state fixture
  instead of hand-rolling init_db + dispose try/finally
- packages/ui/src/lib/utils.ts: add shared toPascalCase util, mirrors
  the Python to_class_name() single source of truth
- pages.ts: use the shared toPascalCase; drop the inline copy
Integrates the pyee-backed EventBus and event-driven dashboard stats
(#17) with this branch's DX hardening work.

Conflict resolutions:

- framework/core/simple_module_core/events.py
  Took main's pyee-backed implementation wholesale. My DX fix added an
  MRO walk to deliver subclass events to base-class subscribers and
  switched publish_nowait to asyncio.get_running_loop(). Main took a
  different direction: exact-type dispatch (a new test explicitly
  asserts `test_subclass_events_do_not_match_parent_subscription`) and
  delegates loop handling to pyee. The MRO feature is incompatible
  with that design; dropped it. publish_nowait's loop concern is now
  encapsulated by pyee.

- modules/dashboard/dashboard/module.py
  Combined both sides' ModuleMeta changes:
  * view_prefix="/dashboard"           (this branch, task 7)
  * route_prefix="/api/dashboard"      (main, for /api/dashboard/stats)
  * depends_on=["Products"]            (main, for event-order dependency)

No changes to tests; 267/267 still pass, ruff/ty/biome/tsc all clean.
@antosubash
antosubash merged commit 574f219 into main Apr 14, 2026
6 checks passed
antosubash added a commit that referenced this pull request Apr 14, 2026
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