# Upgrading to Public API 1.0

Public API 1.0 gives modules one namespace, `sandloom.sdk`, and a declarative form for routes, events and contracts. The migration replaces the earlier internal API without **compatibility aliases**: an old import path stops working and the error names the new one. This page lists every change a module author meets, the replacement, and the check that reports the old form. The full name-by-name table is generated by `tools/packaging/public_api_migration.py`.

> [!NOTE]
>
> **Old names on this page**
>
> This is the one page that names the removed and renamed APIs, to map them to their replacements.

## High impact

### Imports move to `sandloom.sdk`

A module imports only public `sandloom.sdk` modules, generated contract stubs and DTOs, and its own package ([SANDLOOM-C002](<https://sandloom.dev/docs/reference/error-codes/#sandloom-c002>), tests: [SANDLOOM-C013](<https://sandloom.dev/docs/reference/error-codes/#sandloom-c013>)). Every existing name keeps its name and all its parameters at its new home, except the renames and removals below.

| Old home | New home |
| --- | --- |
| `sandloom.core.di` (sessions, dependencies) | `sandloom.sdk.web` |
| `sandloom.core.di` (effects: `record_audit`, `on_commit`, `before_commit`, `durable_inbox`, `operation_key`, `run_async`) | `sandloom.sdk.effects` (`record_audit`, `on_commit` also from `sandloom.sdk`) |
| `sandloom.core.di` (events: `publish_event`, `publish_event_versions`, `EventEnvelope`, `PoisonEventError`) | `sandloom.sdk.events` |
| `sandloom.core.di` (tenancy: `bind_tenant`, `unscoped`, `scoped`, `tenant_filter`, `current_tenant_id`) | `sandloom.sdk.tenant` |
| `sandloom.core.di` (security: token, revocation and block helpers, password hashing) | `sandloom.sdk.security` |
| `sandloom.core.di` (`cache`, `notifier`, `get_core`, `settings`) | `sandloom.sdk.infra` |
| `sandloom.core.di` (sagas: `start_saga`, `saga_status`) and `sandloom.core.module_api` (`Saga`, `step`, `local_step`) | `sandloom.sdk.sagas` |
| `sandloom.core.di` exceptions | `sandloom.sdk.errors` |
| `sandloom.core.module_api` (`define_module`, `ModuleAPI`) and `sandloom.core.descriptors` | `sandloom.sdk.module` (`define_module` also from `sandloom.sdk`) |
| `sandloom.core.module_api` (`contract_method`, the contract runtime) | `sandloom.sdk.contracts` |
| `sandloom.core.orm` | `sandloom.sdk.data` (`Base`, `TenantScoped` also from `sandloom.sdk`) |
| `sandloom.core.projections` | `sandloom.sdk.projections` |
| `sandloom.core.facade` | `sandloom.sdk.infra` |
| `sandloom.shared.authz` | `sandloom.sdk.authz` (the beginner atoms also from `sandloom.sdk`) |
| `sandloom.shared.guards.auth`, `sandloom.shared.guards.platform` | `sandloom.sdk.web` |
| `sandloom.core.testing`, `sandloom.core.contract_testing`, `sandloom.core.testing.fixtures` | `sandloom.sdk.testing`, `sandloom.sdk.testing.fixtures` |
| `sandloom.core.app_factory.create_app` | `sandloom.sdk.app` |
| `sandloom.core.migrations.run_module_migrations` | `sandloom.sdk.migrations` |

`sandloom docs <name>` prints any name's new home and signature.

### Route policy: no operation classes

ADR-0009 removed route classes and freshness knobs. Every route is authenticated unless it says `public(reason=...)`, and every authenticated route reads the session record live ([Route policy and sessions](<https://sandloom.dev/docs/modules/http/route-policy/index.md>)).

| Removed | Replacement |
| --- | --- |
| `operation_class("P")` | `public(reason=...)`; issuers `public(reason=..., issues_tokens=True)` |
| `operation_class("R" \\| "RS" \\| "W" \\| "F" \\| "A")` | nothing: the policy is derived; risk is declared at its source |
| `strict_revocation()`, `revocation_freshness(...)`, `MAX_REVOCATION_STALENESS_SECONDS` | nothing: every route is strict |
| `AUTH_REVOCATION_STRICT_PATH_PREFIXES` | nothing (in `config_tiers.REMOVED`, inert) |
| `revocation_at_commit()` | automatic on writes; `at_commit()` for a non-write route that commits |
| the class-F guard check (`FinancialGuardError`) | mandatory money dedupe ([SANDLOOM-R009](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r009>), [SANDLOOM-R010](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r010>)) and [SANDLOOM-B019](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b019>) |
| `assert_operation_classes(app)`, `operation_classes(app)` | `assert_route_snapshot(app)`, `route_policy(route)` |

A route that is neither authenticated nor public fails at startup ([SANDLOOM-B008](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b008>)). Retired codes: SANDLOOM-B003, SANDLOOM-B004, SANDLOOM-C016.

### Billing atoms name their account

`Subscribed`, `SubscriptionActive`, `Credits`, `Feature` and `WithinLimit` take a required `of=` ([SANDLOOM-B017](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b017>)):

| Old | New |
| --- | --- |
| `Subscribed()` (default `tenant_type="user"`) | `Subscribed(of=Account.user())` |
| `Subscribed(tenant_type="organization", org_param="org_id")` | `Subscribed(of=Account.org(param="org_id"))` |
| `SubscriptionActive(tenant_type="organization")` on a route without the parameter | `SubscriptionActive(of=Account.org(param=None))` |
| `Feature(key)`, `WithinLimit(key, used)` | `Feature(key, of=Account.user())`, `WithinLimit(key, used, of=Account.user())` |

An organization route with `of=Account.user()` needs a reason ([SANDLOOM-C027](<https://sandloom.dev/docs/reference/error-codes/#sandloom-c027>)). See [Authorization](<https://sandloom.dev/docs/modules/http/authorization/#billing-subjects-of-is-required>).

**Billing API behaviour change:** without `org_id`, `/subscription`, `/credits`, checkout and portal use the caller's personal account. A member of exactly one organization no longer gets that organization's account implicitly: pass `org_id`.

### Commands

| Old | New |
| --- | --- |
| `sandloom-scaffold <name>`, `sandloom scaffold` | `sandloom add module <name>` ([how-to](<https://sandloom.dev/docs/modules/create/index.md>)) |
| `sandloom-scaffold saga <module> <name>` | `sandloom add saga <module> <name>` ([how-to](<https://sandloom.dev/docs/modules/between/add-saga/index.md>)) |
| `sandloom-migrate <command>` | `sandloom migrate <command>` ([how-to](<https://sandloom.dev/docs/modules/database/run-migrations/index.md>)) |
| `sandloom-migrate revision <module> -m "..." --autogenerate` | `sandloom migrate new <module> "..."` (adds row-level security) ([how-to](<https://sandloom.dev/docs/modules/database/write-migration/index.md>)) |
| adding the module to `SANDLOOM_MODULES` / the default module list and `testpaths` | nothing: modules are discovered (app modules statically, module packages by their `sandloom.modules` entry point); `SANDLOOM_MODULES` selects a subset |

## Medium impact

### Declarative module surface

The level-2 forms still work; level 1 is shorter and is what the generators write ([Module internals](<https://sandloom.dev/docs/modules/guarantees/module-internals/index.md>)).

| Old (level 2) | New (level 1) |
| --- | --- |
| `define_module(__name__, events=EVENT_SCHEMAS, setup=...)` with hand-written JSON Schemas | `class E(Event, channel="m.e.v1")` in `events.py`; discovered |
| `await publish_event("m.e.v1", {...})` | `await E(...).publish()` |
| `core.register_event_consumer(channel, handler, name=..., bind_tenant_from="tenant_id")` in `setup` | `@on(View)` on `async def f(event: View) -> None` in `consumers.py` |
| `@classmethod @contract_method(...)` methods and `core.register_service(...)` | `@contract("x.y.v1")` class with plain `async def` methods; `@command(idempotency_key=...)` |
| `ContractRequirement(...)` in `consumes=` and `core.get_service("x.y.v1")` | `quota = Contract(X_Y_V1)` at module level (consumes is derived); `Contract(X_Y_V1, optional=True).client()` when absence is a correct answer |
| `step("s", call="billing.credits.v1.spend", ...)` | `step("s", call=credits.spend, ...)` |
| `QueryProjectionDescriptor` + consumers + `monotonic_upsert` + `ProjectionRebuilder` | `Projection(...)` with `SourceGroup`s and mappers |
| `router = APIRouter(...)` with auth and tenant dependencies | `router = module_router()` |

### Sessions

| Old | New |
| --- | --- |
| `db: DbSession` parameters threaded through services | `db()` inside services (`DbSession` stays, advanced) |
| `async with db_session():` | `async with open_uow():` |
| `contract_session()` inside a contract method | `db()` |
| a session passed to `asyncio.gather` children | each child opens `open_uow()` ([SANDLOOM-R013](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r013>)) |
| a second writing session in one request | refused at its first write ([SANDLOOM-R007](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r007>)); `writes("x")` or `allow_multi_db_write(...)` |

### Idempotency

Every authenticated write that carries `Idempotency-Key` is deduplicated automatically, and the retry gets the stored **status only** (mode S). A client that relied on a replayed body re-reads the resource with a `GET`, or the route declares `replay_safe(reason=...)` after review. Explicit `idempotency_guard(...)` calls keep all their options ([Make a write idempotent](<https://sandloom.dev/docs/modules/http/idempotent-write/index.md>)).

### Tests

| Old | New |
| --- | --- |
| `make_user_headers(...)` | `as_user(...)` (same parameters and result) |
| `deliver_event(channel, payload, ...)` | `deliver(event_or_channel, payload=None, ...)` (same parameters; also takes an `Event`) |
| `capture_effects(...)` for published events | `published(E)` |
| a hand-built fake service bound in the registry | `fake_contract(REF, impl)` |
| `-p sandloom.core.testing.plugin` | `-p sandloom.sdk.testing.plugin` |

See the [Testing reference](<https://sandloom.dev/docs/reference/testing/index.md>).

## Low impact

| Old | New |
| --- | --- |
| `Depends(get_current_user_id)` | `user: CurrentUser` (`user.user_id`); the function stays as `sandloom.sdk.web.current_user_id` |
| function-local imports in modules | top-level imports ([SANDLOOM-C021](<https://sandloom.dev/docs/reference/error-codes/#sandloom-c021>)); a marked optional dependency is allowed |
| `routes.snapshot.json` regenerated by hand | regenerated by `sandloom generate` with a compatibility check |

## Verify

```
uv run sandloom check        # reports every old import and form with its fix
uv run sandloom test --full  # tests, gates and generated files
```
