ADR-0001: Framework distribution & packaging¶
- Status: Accepted
- Date: 2026-07-01
- Deciders: Sandloom maintainers
- Supersedes: —
Historical design
This ADR records the original distribution design. The current four-package layout is documented in Packages, and the release procedure in Publishing. Package names and layout examples below describe the design at the time of this decision.
Context¶
At the time of this decision, Sandloom used a single repository (backend/)
that built one wheel
(sandloom-framework) containing the kernel and the bundled business modules
(identity, tenancy, billing) and all infrastructure drivers
(SQLAlchemy, Redis, Celery, SMTP, filesystem).
We need a distribution model that answers, coherently:
- How does a consumer obtain Sandloom and build a SaaS on it?
- How are security patches to cross-cutting platform code (auth, billing webhooks) delivered without forcing every consumer to fork?
- Where is the boundary between "the framework" and "the consumer's product"?
- How are independent components versioned, made compatible, and published?
The current single-wheel layout conflates three very different kinds of code,
and two distribution signals already contradict each other: a pip-installable
sandloom-framework package (library model) and an sandloom-scaffold / sandloom new
project generator (template model).
Verification of current boundaries (from the codebase)¶
A boundary audit of sandloom.core confirmed the kernel is cleanly separable at
runtime, with a few leaks to remediate before the split:
- No real code import of
identity/tenancy/billingfromcore— only strings, comments, config defaults, and tests. sandloom.shared→sandloom.core.diis one-directional (acyclic). Apparentcore → sharedhits are only in scaffold template strings (generated code) and integration tests undercore/tests/.- SQLAlchemy / Redis / Celery are confined to
drivers/*(formerlyinfra/drivers/*, ADR-0007) and the relational surface (orm,di,sql_alchemy/,projections/,migrations/). The paradigm-agnostic kernel is SDK-free. - Decisive fact for the infra boundary: the relational paradigm is not
behind a swappable contract — UoW,
outbox_store,audit_store,durable_inbox, tenant scoping, projections and migrations are direct SQLAlchemy code inside the kernel.import sandloom.core.dicannot load without SQLAlchemy. The settings validator (_db_dsn_ok) further mandatespostgresql+asyncpg://. The framework is therefore Postgres-relational by construction today; an in-memory/SQLite relational default would be a lie.
The enforced public contract (from architecture_rules.yml, checked by
tools/checks/check_architecture.py as a strict whitelist) is exactly six modules:
sandloom.core.facade, sandloom.core.di, sandloom.core.module_api,
sandloom.core.descriptors, sandloom.core.orm, sandloom.core.projections. The prose list
in AGENTS.md (which also names settings, idempotency, exceptions,
security, lifecycle, runtime as a stable surface) is broader than the
enforced contract and is corrected to match.
Decision¶
1. Consumption model — three tiers¶
- Kernel = library.
sandloom-frameworkis a versioned pip package, read-only to consumers, upgraded by version bump. - Platform modules = versioned packages.
identity/tenancy/billingship as separate versioned packages, not scaffold — so security-sensitive code (JWT, RBAC, 2FA, SSO, Stripe webhooks, credits ledger) is patchable centrally by a version bump rather than a per-consumer fork. - Domain modules = scaffold. The consumer's own product modules are
generated by
sandloom newand owned by the consumer.
2. Package graph (monorepo + uv workspace, N wheels, independent versions)¶
sandloom-framework— kernel plus the relational spine (hard deps:fastapi,uvicorn,pydantic,pydantic-settings,sqlalchemy,alembic,asyncpg,orjson,jsonschema,prometheus-client,pyjwt,passlib/argon2). Bundles zero-dependency infra defaults in-tree: in-memory cache, in-memory event bus, filesystem object store, log notifier. Ships nomain:app.sandloom-shared— thin layer above the kernel (depends onsandloom-framework); the second public surface (sandloom.shared.*).sandloom-identity,sandloom-tenancy,sandloom-billing— platform packages.sandloom-infra-redis,sandloom-infra-celery,sandloom-infra-smtp,sandloom-infra-s3— opt-in backend plugins registered via thesandloom.infraentry-point group.sandloom-platform— thin meta-package ("batteries"): depends onsandloom-framework[postgres]+sandloom-identity+sandloom-tenancy+sandloom-billing; carriescreate_app(),main:app, and the default module list.- Consumer domain modules — scaffolded, consumer-owned.
The relational backend server is swappable only by DSN within asyncpg;
Redis / Celery / SMTP / S3 are the genuinely swappable infrastructure and become
opt-in packages. The kernel boots on Postgres + in-memory everything else.
3. Compatibility contract¶
- SemVer is governed by the enforced
public_api(the six modules above) plussandloom.shared.*, plus behavioural/wire contracts (event channel names*.vN, capability contracts) which are versioned separately from the import API. - A CI gate snapshots the public surface (e.g.
griffe) and fails the build when it changes incompatibly without a version bump. The existingcheck_architecture.pyonly stops consumers from crossing the line; this gate stops the line from moving silently. - 0.x policy: lockstep-minor / independent-patch. All workspace packages
move together on a minor (one minor = one compatible set); security/bug fixes
patch independently. Satellites pin
sandloom-framework ~=0.N.
4. Assembly¶
sandloom-platform is the assembly point. sandloom.main:app and the default module list
move out of the kernel into sandloom-platform. Consumers either depend on
sandloom-platform (batteries) or on bare sandloom-framework (--minimal) and call
create_app(modules=[...]) in their own owned app.py.
5. Scaffold mechanics (sandloom new)¶
sandloom new <project>generates a consumer-owned app project depending onsandloom-platform ~=0.Nby default (--minimal→ baresandloom-framework). It emitsapp.py(create_app(modules=[...])),pyproject.toml(compatible-release pins) plus a committeduv.lockfor reproducibility,.env.example, Alembic wiring, a consumer-ownedDockerfile, compose, and a CI skeleton.sandloom new module <name>generates a domain-module skeleton inside the project (owned code).- Templates ship inside the
sandloom-frameworkCLI and emit code that imports only the public surface.
6. Distribution channels¶
- Canonical channel: public PyPI wheels, namespace
sandloom-*. - Publish functional distributions, with matching metadata and source archives; an empty placeholder release is not a distribution strategy.
- Source development: use editable local dependencies. Portable deployments install published packages or a complete matching wheel set.
- Trusted Publishing (PyPI OIDC) authenticates package uploads without long-lived API tokens in CI. The checked-in workflow is build-only; enabling uploads requires the publisher configuration and explicit workflow changes described in the current publishing guide.
- Docker → GHCR is a secondary track for the reference
sandloom-platformapp only (quickstart / self-host), not a framework consumption channel. There is noFROM sandloombase image; the scaffold emits a consumer-owned Dockerfile that installs the pinned packages.
7. Release engineering¶
- Automated, per package:
python-semantic-release, scope/path-aware conventional commits (feat(identity): …,fix(core): …). The bot computes the version and changelog from the commits touching that package and pushes a per-package tagsandloom-<pkg>-vX.Y.Z; CI builds and publishes only the changed package. - The griffe public-surface snapshot and the lockstep-minor gate remain on
top as a safety net: a
feat:that breaks the public surface without the corresponding bump still fails CI. Automation derives versions; the gates stop it from lying. - Per-package CHANGELOG (Keep a Changelog); the root changelog becomes a framework-wide aggregator.
Consequences¶
Positive¶
- Security patches to platform modules ship as version bumps, never forks.
- A clear, machine-enforced framework/product boundary.
- Lightweight kernel footprint (no Redis/Celery/SMTP/S3 unless opted in).
- Atomic cross-cutting refactors in one monorepo PR, with independent release cadence per package.
Negative / costs¶
- Repository restructure into
packages/*, each with its ownpyproject.toml. - More CI machinery (per-package build/publish matrix, griffe gate, lockstep gate).
- Architecture tests must enforce package boundaries, not only intra-tree imports.
Boundary remediations required before the split¶
- Remove the satellite names baked into the kernel:
settings.pydefaultssandloom_modules = ["sandloom_identity", "sandloom_tenancy", "sandloom_billing"]andmodule_required = ["sandloom_identity"]move tosandloom-platform/ scaffold. - Move
core/tests/*that importidentity/tenancy/billing/sharedinto a workspace-leveltests/integrationpackage (they cannot ship inside the kernel wheel's suite). - Keep only zero-dependency drivers in the kernel; move Redis / Celery / SMTP /
S3 drivers into
sandloom-infra-*. - Move
main:app+ composition intosandloom-platform. - Dependency hygiene:
pytest,factory-boy,pytest-xdist,pytest-*are currently in the kernel's runtimedependencies— move them to[dev]so consumers ofsandloom-frameworkdo not pull the test stack at runtime. - Narrow the capability docs honestly: "database is a freely swappable paradigm" is untrue while only the SQLAlchemy/Postgres driver exists and the PG-only validator stands. SQLite-for-tests is roadmap, not a v0.1.0 promise.
Implementation status¶
All six remediations are done, and the packaging split has landed (source of
truth stays in src/sandloom/**; each packages/<name> builds a
distributable wheel via hatchling force-include):
-
1 (satellite names out of the kernel) and #3 (lean kernel — Redis/Celery are¶
optional extrassandloom-framework[redis|celery|infra], with thinsandloom-infra-redis/sandloom-infra-celerymeta-packages) — done in PR #18. -
2 (integration tests moved to
tests/integration), #4 (main:appin¶sandloom-platform), #5 (test deps →[dev]), and #9 (packages CI build/compose + lean-import gate) — done in PRs #17 and earlier. - See
packages/README.mdfor the current tiers and build hook. The original implementation follow-up notes are archived separately from the maintained source.
Distribution & release machinery (this ADR's §3/§5/§6/§7):
- §3 versioning — a public-API surface gate (
tools/packaging/check_public_api.py - committed
public_api_baseline.json) fails the build on a backward-incompatible change to the enforced public surface; wired into the Architecture Check workflow. - §5 scaffold —
sandloom newdepends onsandloom-platformin batteries mode and baresandloom-frameworkfor--minimal/--no-batteries(matching the tiers). - §6/§7 distribution & release — per-package changelogs, a per-package tag
scheme
sandloom-<pkg>-vX.Y.Z, and a Trusted-Publishing (OIDC, no-secrets) release workflow that fails closed until PyPI pending publishers exist. Operator steps for claiming the PyPI names and configuring pending publishers are indocs/contributing/publishing.md— these are the only steps that must be done by a human on pypi.org. - Intentionally deferred: the
sandloom-infra-smtpmeta-package (SMTP driver is stdlib-only), thesandloom-infra-s3meta-package (no S3 driver exists yet), a SQLite-for-tests relational driver (roadmap), andpython-semantic-releasefull automation (optional polish).
Update 2026-10-28: four distributions. The eight packages above became four, and the module packages became ecosystem modules shipped the way a third party ships one. The text above records the earlier layout.
sandloomis the framework: the kernel,sandloom.sdk,sandloom.shared(framework DTOs, guards, authz), the CLI and the composition rootsandloom.main. Its extras areredis,celery,standard(Redis + Celery + the three modules, the formersandloom-platform),fast,check,testingandsentry.sandloom-framework,sandloom-shared,sandloom-platform,sandloom-infra-redisandsandloom-infra-celeryare removed outright before the initial public alpha (no shims).sandloom-identity,sandloom-tenancyandsandloom-billingship the top-level import packagessandloom_identity,sandloom_tenancyandsandloom_billing(source insrc/sandloom_<module>/), each with onesandloom.modulesentry point whose name is the module name. They depend onsandloom>=0.7,<0.8and on no other module; versions are independent and start at 0.7.0.sandloom.main:appcomposes every installed entry point whenSANDLOOM_MODULESis unset; the kernel names no module. A requested module name with two providers fails startup (SANDLOOM-B020).sandloom.sharedis unchanged and ships whole inside thesandloomdistribution (contract DTOs, the standard modules' generated stubs and event schemas, authz, guards): every caller process imports it (SANDLOOM-C012).- Dependencies are ranges (
>=tested,<next breaking release); exact versions live only inuv.lock. tools/packaging/check_package_distributions.py(CI:.github/workflows/packages.yml) builds the four wheels and sdists, checks import layering across distributions, installssandloomalone to run every CLI subcommand, and installssandloom[standard]to compose the modules.sandloom newdepends onsandloom[redis,celery]>=0.7,<0.8plus onesandloom-<module>>=0.7,<0.8per--withmodule. Tags aresandloom-vX.Y.Zandsandloom-<module>-vX.Y.Z.
Alternatives considered¶
- Single wheel with extras (
sandloom-framework[identity,billing], one version for everything). Rejected: kills independent security patching of platform modules — the core reason for the tiering. - Polyrepo (one git repo per package). Rejected for 0.x: cross-cutting changes become N-PR synchronization with no offsetting benefit yet.
- Contracts-only kernel (relational/Postgres fully extracted into
sandloom-infra-sqlalchemy, kernel depends only on capability contracts). Rejected: the relational paradigm is structurally in the kernel (UoW/outbox/audit/ migrations are SQLAlchemy code, andimport sandloom.core.direquires it). Making it "optional" would be dishonest and require a large refactor for an abstraction the framework does not actually want. - Manual versioning (
bump-my-version+ per-package tags, human decides the number). Rejected in favour of automation; the griffe + lockstep gates are retained regardless as the forcing function.