Skip to content
Menu

Documentation

Error catalogue

Every framework diagnostic carries a stable code. A message at the failure site links directly to its section on the website and gives an offline help command:

error[SANDLOOM-B008]: route POST /api/v1/notes/ is neither authenticated nor public()
  --> notes/api.py:12 (create_note)
  fix: ...
  docs: https://sandloom.dev/docs/reference/error-codes/#sandloom-b008
  help: sandloom explain SANDLOOM-B008

sandloom explain <code> (or the slug, e.g. sandloom explain route-policy) prints the long form below; sandloom explain --list lists every code. Families: SANDLOOM-C static sandloom check rules, SANDLOOM-B startup refusals, SANDLOOM-R runtime errors, SANDLOOM-T test-harness and snapshot gates. Codes are never reused or renumbered; a retired code stays listed.

Index

Code Slug Kind What happened Status
SANDLOOM-T016 admin-manifest test the admin console's manifest is stale or cannot be compiled active
SANDLOOM-T015 inspection-inputs test module context or declared architecture inspection cannot use its inputs active
SANDLOOM-C001 module-boundaries check a business module imports a sibling business module active
SANDLOOM-C002 framework-imports check import outside the framework public API active
SANDLOOM-C003 raw-provider check provider escape hatch (raw_client / raw_backend / raw) in module code active
SANDLOOM-C004 infra-imports check infra-internal module, legacy global or backend SDK imported active
SANDLOOM-C005 kernel-handles check kernel handle reached by attribute in module code active
SANDLOOM-C006 uow-boundary check db.commit() / db.rollback() in module code active
SANDLOOM-C007 best-effort-effects check an event publish or record_audit downgraded to best-effort without a marker active
SANDLOOM-C008 get-core-await check get_core(request) called without await active
SANDLOOM-C009 endpoints-outside-api check HTTP endpoint outside the module's api/ layer active
SANDLOOM-C010 orm-leakage check ORM model / SQLAlchemy in an endpoint file active
SANDLOOM-C011 schema-orm-imports check schemas/ imports ORM models or SQLAlchemy active
SANDLOOM-C012 contract-dtos check a contract DTO the generator cannot copy, or a hand-edited / stale generated copy active
SANDLOOM-C013 test-imports check module test imports outside its whitelist active
SANDLOOM-C014 tenant-scoped-models check a per-tenant table is not TenantScoped active
SANDLOOM-C015 unsafe-tenant-sql check runtime raw SQL / Core table access can bypass the tenant guard active
SANDLOOM-C016 route-operation-class check a route declares no ADR-0003 operation class (retired) retired (never reused)
SANDLOOM-C017 event-schema-channels check an event is published on a channel the module registers no schema for active
SANDLOOM-C018 guard-order check a guard or tenant-dependent gate is declared before what it needs active
SANDLOOM-C019 consumed-contracts check get_service names a contract the module does not declare in consumes= active
SANDLOOM-C020 db-dependency-scope check Depends(get_db) without scope="function" active
SANDLOOM-C021 module-local-imports check a function-local import in a business module, example or template active
SANDLOOM-C022 untagged-sensitive check an effect that looks sensitive carries no risk tag (warning) active
SANDLOOM-C023 multi-store-waivers check lists every allow_multi_db_write opt-out for review active
SANDLOOM-C024 unsafe-tenant-query check a raw query on a non-relational store outside unscoped() active
SANDLOOM-C025 event-view-compat check an event view is incompatible with (or unverified against) its producer, or a view calls .publish() active
SANDLOOM-C026 contract-union-hint check a contract signature uses an ambiguous union (information) active
SANDLOOM-C027 account-route-mismatch check a route with an organization parameter gates billing with of=Account.user() active
SANDLOOM-B001 module-load-failed boot a module failed to load active
SANDLOOM-B002 guard-order-startup boot idempotency_guard resolves before the route's authentication active
SANDLOOM-B003 financial-guard boot a class F route used a response-storing or fail-open guard (retired) retired (never reused)
SANDLOOM-B004 operation-class-contradiction boot a route's operation-class declarations contradicted each other (retired) retired (never reused)
SANDLOOM-B005 contract-binding boot a required consumed contract is neither provided nor bound active
SANDLOOM-B006 module-registration boot a module's registration is invalid active
SANDLOOM-B007 gate-order-startup boot an authz gate that needs an organisation runs before TenantScope() active
SANDLOOM-B008 route-policy boot a route is neither authenticated nor public(), is both, or gives no reason active
SANDLOOM-B009 write-store-ambiguous boot a route injects several writable sessions without writes() or an opt-out active
SANDLOOM-B010 guarantee-unsupported boot the write store lacks a feature the route defaults need active
SANDLOOM-B011 underived-store-write boot a non-transactional store is injected into a route with defaults active
SANDLOOM-B012 projection-contract boot a projection breaks its convergence contract active
SANDLOOM-B013 multi-store-waiver boot an invalid allow_multi_db_write declaration active
SANDLOOM-B014 money-effect-outside-write-store boot a money effect sits in an opted-out secondary store active
SANDLOOM-B015 event-definition boot an invalid Event class active
SANDLOOM-B016 consumer-definition boot an invalid @on consumer active
SANDLOOM-B017 authz-subject-missing boot a billing atom has no of= subject active
SANDLOOM-B018 contract-definition boot an invalid @contract class active
SANDLOOM-B019 capability-replay boot a token issuer, intent or money route has the wrong idempotency guard or an intent route a request session active
SANDLOOM-B021 driver-conflict boot a selected driver or capability name has two providers active
SANDLOOM-B022 driver-load-failed boot a selected driver or capability cannot be used active
SANDLOOM-B023 experimental-capability-refused boot an experimental capability is configured in production active
SANDLOOM-B020 ambiguous-module boot a requested module name has two providers (two installed packages, or an installed package and a local module) active
SANDLOOM-R001 missing-tenant runtime a tenant-scoped operation ran with no tenant bound active
SANDLOOM-R002 unit-of-work-required runtime db(), an event publish or record_audit ran outside a Unit of Work active
SANDLOOM-R003 event-schema-missing runtime an event was published on a channel with no registered schema active
SANDLOOM-R004 event-payload-invalid runtime an event payload does not match its channel's schema active
SANDLOOM-R005 service-unavailable-in-deployment runtime a contract is neither provided locally nor bound remotely active
SANDLOOM-R006 unhandled-exception runtime an endpoint raised an unhandled exception active
SANDLOOM-R007 multi-store-write runtime a second unit of the request tried to write active
SANDLOOM-R008 nested-or-concurrent-unit runtime a unit, read scope or connection started where the unit rules forbid it active
SANDLOOM-R009 money-effect-without-dedupe runtime a money effect ran without an effect dedupe record active
SANDLOOM-R010 idempotency-key-required runtime a sensitive write was called without an Idempotency-Key (428) active
SANDLOOM-R011 step-up-required runtime a security-sensitive route needs a fresh authentication (401, RFC 9470) active
SANDLOOM-R012 intent-release-unchecked runtime an operation-intent route answered 2xx outside the two release cases, or release_check() lacked the authority active
SANDLOOM-R013 concurrent-session-use runtime db() (or the unit's session) was used from a task that does not own the unit active
SANDLOOM-T001 route-snapshot test a module's routes.snapshot.json does not match its routes active
SANDLOOM-T002 migration-chain test a table or index is not created by its owning migration chain active
SANDLOOM-T004 contract-snapshot test a module's contracts.snapshot.json does not match its contracts active
SANDLOOM-T003 module-not-deployed test a module is not deployed in a deploy profile (warning) active
SANDLOOM-T005 event-schema-compat test a breaking change to an event schema on the same .vN active
SANDLOOM-T006 migration-drift test the models differ from the head of their migration chain active
SANDLOOM-T007 contract-stub-stale test a generated contract stub is out of date active
SANDLOOM-T008 public-api-baseline test the public API surface or a reference block changed active
SANDLOOM-T009 beginner-tier test a module imports an advanced sandloom.sdk name without a marker active
SANDLOOM-T010 periodic-tasks-need-a-beat test a module with periodic tasks has no beat in a deploy profile active
SANDLOOM-T011 route-index test the route dispatch index disagrees with the linear scan, or one bucket holds half the routes active
SANDLOOM-T012 redundant-index test a plain index duplicates the leading column(s) of a unique key active
SANDLOOM-T013 tenant-natural-key test a TenantScoped table has a key without tenant_id (warning) active
SANDLOOM-T014 split-profile test the microservices profile breaks a split invariant active

Static checks (sandloom check)

SANDLOOM-C001

a business module imports a sibling business module

Slug: module-boundaries · kind: check · status: active

What happened and why it matters

Modules are atomic and isolated: each one can be deployed as its own service. An import of another business module (any spelling: absolute, relative, importlib) couples the two at the code level, so the second module would have to ship in every process that loads the first. A module-level Contract(...) reference declares the dependency (consumes= is derived).

Fix: publish an Event subclass (await NoteCreated(...).publish()) and react to it with @on(NoteCreated) in the other module, or call its contract through Contract(X_Y_V1) from the generated sandloom.shared.contracts stub; never import the other module

More: AGENTS.md#the-one-rule-that-matters

SANDLOOM-C002

import outside the framework public API

Slug: framework-imports · kind: check · status: active

What happened and why it matters

A business module may import only the public API (sandloom.sdk.: web, data, events, contracts, authz, testing, ...), sandloom.shared. and its own package. Importing a kernel module (sandloom.core.*) couples the module to code that changes without notice; every name a module needs is re-exported by sandloom.sdk.

Fix: import only sandloom.sdk. (the public API), sandloom.shared. and your own package

SANDLOOM-C003

provider escape hatch (raw_client / raw_backend / raw) in module code

Slug: raw-provider · kind: check · status: active

What happened and why it matters

raw_client() / raw_backend() hand out the backend SDK object. Module code that uses it only runs on that backend, which defeats the pluggable infrastructure layer. Use the operations of the capability contract instead.

Fix: use the portable capability surface (cache.get/set, taskqueue.enqueue, ...)

SANDLOOM-C004

infra-internal module, legacy global or backend SDK imported

Slug: infra-imports · kind: check · status: active

What happened and why it matters

Modules know infrastructure only by logical name. Backend SDKs (boto3, motor, neo4j, ...), the legacy globals and the infra-internal packages are blocked; the drivers construct every backend. sandloom.driverkit and a driver package's own module are blocked too: a module asks for a capability, and a package's contracts module is what it may import.

Fix: use db() or Database(name) for tables, cache() / notifier() / capability() from sandloom.sdk.infra for infrastructure, and Event.publish() for events

SANDLOOM-C005

kernel handle reached by attribute in module code

Slug: kernel-handles · kind: check · status: active

What happened and why it matters

core.event_bus, core.infra, core.service_registry and request.app.state.core are kernel internals. A module reaches kernel services only through their public verbs.

Fix: use the public verbs of sandloom.sdk: Event.publish() / @on(E) for events, cache() / objectstore() / capability() from sandloom.sdk.infra, and Contract(REF) for contracts

SANDLOOM-C006

db.commit() / db.rollback() in module code

Slug: uow-boundary · kind: check · status: active

What happened and why it matters

DbSession is one transaction per request (or consumer delivery / task). The outbox, the audit row and the on-commit hooks commit with it. A manual commit splits that transaction: an event could be published for a change that is later rolled back.

Fix: remove the call: the Unit of Work commits on success and rolls back on any exception; use flush() and begin_nested()

SANDLOOM-C007

an event publish or record_audit downgraded to best-effort without a marker

Slug: best-effort-effects · kind: check · status: active

What happened and why it matters

required=False publishes straight to the bus (no outbox, no retry) or skips the audit row when no UoW is active. That is a deliberate weakening, so it must be visible in review.

Fix: drop required=False and run inside a Unit of Work, or justify it with # sandloom: best-effort <reason> on the call

SANDLOOM-C008

get_core(request) called without await

Slug: get-core-await · kind: check · status: active

What happened and why it matters

get_core is async since 0.6.0; a missing await yields a coroutine.

Fix: await get_core(request) or the sync core_from_request(request)

SANDLOOM-C009

HTTP endpoint outside the module's api/ layer

Slug: endpoints-outside-api · kind: check · status: active

What happened and why it matters

define_module mounts api/'s router; endpoints elsewhere are not mounted by convention and escape the api-layer rules.

Fix: move the route into the module's api/ package

SANDLOOM-C010

ORM model / SQLAlchemy in an endpoint file

Slug: orm-leakage · kind: check · status: active

What happened and why it matters

The api/ layer stays persistence-free: endpoints take DbSession, call services and return schemas/ DTOs.

Fix: map to pydantic DTOs in services and keep persistence out of api/

SANDLOOM-C011

schemas/ imports ORM models or SQLAlchemy

Slug: schema-orm-imports · kind: check · status: active

What happened and why it matters

schemas/ are the module's wire contracts; they must not depend on the persistence layer.

Fix: keep DTOs persistence-free

SANDLOOM-C012

a contract DTO the generator cannot copy, or a hand-edited / stale generated copy

Slug: contract-dtos · kind: check · status: active

What happened and why it matters

A provider authors its contract DTOs (pydantic models, Enums) in its own module, by convention its schemas module. sandloom generate writes a wire-shape copy of every DTO a provided contract signature names (nested ones included) into the shared DTO package, next to the stub, which names the copy: every caller process imports the same generated class, and a call returns it locally and over RPC alike. A copy carries the fields, their types and constraints, defaults, aliases, Literal / Enum values, nested models and the model_config options; not methods, properties or validators (a validator is the provider's: it runs where the provider binds an argument or builds its result).

Refused, with the field path and the fix: a DTO outside the closed set of wire shapes (the event set: see events.md, What an event field may be) or one the copy cannot carry (a parametrised generic, a default or default_factory it cannot write, a custom constraint, a callable discriminator) when @contract decorates the provider; a DTO of ANOTHER module's package or a function-local one when the module registers the contract (use the other module's generated copy from the shared DTO package); a missing copy (sandloom check, contract-dtos); a hand-edited or stale copy (sandloom test: run sandloom generate).

Fix: declare the DTO at module level in the provider's own package (its schemas module) inside the closed set of wire shapes, then run sandloom generate

SANDLOOM-C013

module test imports outside its whitelist

Slug: test-imports · kind: check · status: active

What happened and why it matters

Module tests import only the public API (sandloom.sdk.*, the test kit sandloom.sdk.testing included), sandloom.shared and their own package. Seed another module's state through its HTTP API, contracts or events.

Fix: use sandloom.sdk.testing (the client fixture, as_user, deliver, published, fake_contract, fake_access_fact, ...) and other modules' HTTP API / contracts / events

SANDLOOM-C014

a per-tenant table is not TenantScoped

Slug: tenant-scoped-models · kind: check · status: active

What happened and why it matters

A model or Core Table with a tenant-like column (tenant_id, organization_id, org_id, workspace_id, account_id) holds per-tenant rows. Without TenantScoped the ORM tenant guard never filters it, so one forgotten where(organization_id == ...) leaks another tenant's rows, and RLS is never enabled for it.

Tables that are deliberately keyed by organisation but global to the system (the tenancy module's own organisations/memberships, billing's customer records) are listed under tenant_scope_exempt with a written reason. A stale exemption (no matching table any more) also fails.

Fix: declare class X(TenantScoped, Base) (drop the own tenant column) and ship enable_tenant_rls(op, <table>), or list a deliberate global table under tenant_scope_exempt in architecture_rules.yml with a reason

More: docs/reference/security-model.md

SANDLOOM-C015

runtime raw SQL / Core table access can bypass the tenant guard

Slug: unsafe-tenant-sql · kind: check · status: active

What happened and why it matters

text(), exec_driver_sql and Model.table statements are not tenant-filtered by the ORM guard.

Fix: use ORM entities, or an explicit unscoped() block for trusted system work

SANDLOOM-C016

a route declares no ADR-0003 operation class (retired)

Slug: route-operation-class · kind: check · status: retired (never reused)

What happened and why it matters

Retired. A static rule for ADR-0003 operation classes was planned and dropped: the route-policy model that replaces operation classes (deny-by-default auth, explicit public(reason=...), risk tags on the owning effect) gets its own checks. The code is never reused.

Fix: none: the rule was dropped before release

SANDLOOM-C017

an event is published on a channel the module registers no schema for

Slug: event-schema-channels · kind: check · status: active

What happened and why it matters

Every versioned channel needs a registered schema in every profile: the event is refused before it reaches the outbox otherwise. An sandloom.sdk.events Event subclass carries both; the static rule flags a channel spelled as a string (or module-level constant) that no Event class or schema of the module declares. Channels owned by another module are published by that module only.

Fix: declare the event as an Event subclass in the module's events.py (its channel and schema are derived), or fix the channel name

More: docs/reference/event-contracts.md

SANDLOOM-C018

a guard or tenant-dependent gate is declared before what it needs

Slug: guard-order · kind: check · status: active

What happened and why it matters

FastAPI solves a route's dependencies in declaration order: route-decorator dependencies=[...] first, then the endpoint parameters. An idempotency_guard that runs before authentication keys anonymous retries; an authz gate that needs an organisation (SubscriptionActive, Credits, OrgRole, ...) fails with "organization context required" when it runs before TenantScope(). This rule flags the statically visible cases; create_app refuses the rest at startup (SANDLOOM-B002, SANDLOOM-B007).

Fix: list the authentication (and TenantScope()) before idempotency_guard / require(...) gates, or put auth in the router dependencies

SANDLOOM-C019

get_service names a contract the module does not declare in consumes=

Slug: consumed-contracts · kind: check · status: active

What happened and why it matters

consumes= is the module's dependency manifest. The loader checks each entry at startup (provided locally or bound in SERVICE_BINDINGS); an undeclared service(...) / get_service call is only discovered as a 501 at request time in the deployment that lacks the provider.

Fix: call the contract through Contract(REF) (its requirement is derived), or add ContractRequirement("<name>", required=...) to define_module(..., consumes=[...])

SANDLOOM-C020

Depends(get_db) without scope="function"

Slug: db-dependency-scope · kind: check · status: active

What happened and why it matters

Without scope="function" FastAPI keeps the dependency alive until the response is sent: it opens a second Unit of Work and commits it after the response, so a client can act on a 200 before the write is visible.

Fix: use db() from sandloom.sdk.data (the lazy request unit) or DbSession from sandloom.sdk.web; a raw Depends(get_db) needs scope="function"

SANDLOOM-C021

a function-local import in a business module, example or template

Slug: module-local-imports · kind: check · status: active

What happened and why it matters

Function-local imports in modules hide dependencies and get copied by agents. Their causes are gone in 1.0: import-time settings are lazy (spec §2.4) and discovery replaces lazy setup imports. What remains is a marked optional dependency (for example qrcode).

Fix: import at module level; a genuinely optional dependency keeps its local import with # sandloom: optional-import <reason>; typing-only imports go under if TYPE_CHECKING:

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-C022

an effect that looks sensitive carries no risk tag (warning)

Slug: untagged-sensitive · kind: check · status: active

What happened and why it matters

ADR-0009 derives a route's sensitivity from the risk of the effects it reaches. An effect that moves money, changes roles or touches credentials without a risk tag makes its routes look ordinary; the check warns.

The route form is a heuristic: a write route that raises 403 in its body looks like a hand-written authority decision. If the refusal is one, declare the risk, or better, move it to a require(...) policy. If it is a business rule, such as a used-up quota, it is not an authority decision: answer with a status that says so (409 or 422), and the warning goes away.

Fix: tag the owning effect with its risk (money / admin / security) at the source, or declare sensitive(risk) on the route

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-C023

lists every allow_multi_db_write opt-out for review

Slug: multi-store-waivers · kind: check · status: active

What happened and why it matters

allow_multi_db_write(secondaries=, reason=) is the reviewed best-effort 1PC opt-out of the one-writer rule (spec §4.4 rule 4). The check lists every waiver with its reason so a review sees all of them in one place.

Fix: keep each waiver's reason current; remove a waiver whose route no longer writes to a second store

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-C024

a raw query on a non-relational store outside unscoped()

Slug: unsafe-tenant-query · kind: check · status: active

What happened and why it matters

Non-relational stores have no row-level security: a raw query there reads every tenant's data. The check flags raw queries outside an explicit unscoped() block (the relational counterpart is SANDLOOM-C015).

Fix: use the store's tenant-scoped operations, or run deliberate cross-tenant work inside with unscoped():

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-C025

an event view is incompatible with (or unverified against) its producer, or a view calls .publish()

Slug: event-view-compat · kind: check · status: active

What happened and why it matters

A view (an Event class for a foreign channel) is checked against the producer schema found in the manifest or the exported schema file; with neither it is reported as unverified (warning). .publish() on a view is refused: only the producer owns a channel.

Fix: make the view's fields a compatible subset of the producer schema; only the producing module publishes a channel

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-C026

a contract signature uses an ambiguous union (information)

Slug: contract-union-hint · kind: check · status: active

What happened and why it matters

Ambiguous unions such as UUID | str stay supported (codec 1, tagged); this hint only points at the faster fully typed codec (owner decision Q3).

Fix: optional: use a discriminated type so the fully typed codec 2 applies; the union keeps working through codec 1

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-C027

a route with an organization parameter gates billing with of=Account.user()

Slug: account-route-mismatch · kind: check · status: active

What happened and why it matters

A route under /orgs/{org_id} that checks the caller's personal subscription or credits almost always meant the organization's account. The check refuses the combination unless the atom states why (spec §7.3).

Fix: use of=Account.org(param=...) for an organization route, or state Account.user(reason="...") when the personal account is intended

More: tests/fixtures/specs/public-api-1.0-spec.md

Startup refusals (create_app, sandloom check --boot)

SANDLOOM-B001

a module failed to load

Slug: module-load-failed · kind: boot · status: active

What happened and why it matters

Modules are required by default: a module that fails to import or to register aborts startup, so its routes can never silently disappear (a test would otherwise see 404 on its own endpoints). Optionality is explicit: define_module(name, ..., optional=True), or a composition root's default_optional_modules.

Fix: fix the error shown for the module; a module that may legitimately be absent declares define_module(..., optional=True)

SANDLOOM-B002

idempotency_guard resolves before the route's authentication

Slug: guard-order-startup · kind: boot · status: active

What happened and why it matters

See SANDLOOM-C018. create_app checks the solved dependency order (GuardOrderError).

Fix: declare the authenticator (CurrentUser / current_user_id from sandloom.sdk.web) before the guard, or authenticate the whole router (module_router)

SANDLOOM-B003

a class F route used a response-storing or fail-open guard (retired)

Slug: financial-guard · kind: boot · status: retired (never reused)

What happened and why it matters

Retired with the operation classes. A money route (sensitive("money")) with a response-storing or fail-open guard is refused as SANDLOOM-B019, and every money effect needs a dedupe record (SANDLOOM-R009) or the client's Idempotency-Key (SANDLOOM-R010). The code is never reused.

Fix: none: operation classes were removed (ADR-0009)

SANDLOOM-B004

a route's operation-class declarations contradicted each other (retired)

Slug: operation-class-contradiction · kind: boot · status: retired (never reused)

What happened and why it matters

Retired with the operation classes: the route policy is derived (deny-by-default auth, SANDLOOM-B008; risk at its source) and pinned by the route snapshot (SANDLOOM-T001). The code is never reused.

Fix: none: operation classes were removed (ADR-0009)

SANDLOOM-B005

a required consumed contract is neither provided nor bound

Slug: contract-binding · kind: boot · status: active

What happened and why it matters

The loader checks every contract a module references (its derived consumes=) at startup (ContractBindingError).

Fix: load the providing module in this process, bind the contract in SERVICE_BINDINGS, or reference it as Contract(X_Y_V1, optional=True) and degrade

SANDLOOM-B006

a module's registration is invalid

Slug: module-registration · kind: boot · status: active

What happened and why it matters

Module registration is transactional: the whole draft is validated before anything is applied (ModuleRegistrationConflict, ContractTypeError, ServiceRegistryError). The module then fails to load (SANDLOOM-B001).

Fix: fix the reported registration (contract members must be @contract_method with wire-typed signatures and no session parameter; service / channel names versioned .vN; no duplicate registrations)

SANDLOOM-B007

an authz gate that needs an organisation runs before TenantScope()

Slug: gate-order-startup · kind: boot · status: active

What happened and why it matters

Gates such as SubscriptionActive, Credits, Entitled and OrgRole resolve the organisation from the bound tenant. FastAPI solves decorator dependencies before endpoint parameters, so dependencies=[Depends(require(...))] next to a TenantScope() parameter runs the gate with no tenant bound and every call fails with "organization context required".

Fix: declare TenantScope() before the gate: move require(...) from the decorator dependencies into the endpoint signature after the TenantScope() parameter, or put TenantScope() in the router dependencies

SANDLOOM-B008

a route is neither authenticated nor public(), is both, or gives no reason

Slug: route-policy · kind: boot · status: active

What happened and why it matters

Model E (ADR-0009) denies by default: every route is authenticated unless it says public(reason=...). A route with both an authenticator and public(), or public() without a reason, is ambiguous and refused at startup.

Fix: authenticate the route (module_router() does it, or CurrentUser / CurrentService), or declare public(reason="..."); never both

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B009

a route injects several writable sessions without writes() or an opt-out

Slug: write-store-ambiguous · kind: boot · status: active

What happened and why it matters

The write store is inferred statically: DbSession / db() means the module's primary database, one Database("x") means x. With several writable sessions the route must name its write store, which also holds its idempotency, audit and outbox records (spec §6.1).

Fix: declare writes("<database>") on the route, or allow_multi_db_write(secondaries=..., reason=...)

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B010

the write store lacks a feature the route defaults need

Slug: guarantee-unsupported · kind: boot · status: active

What happened and why it matters

The default route guarantees (durable idempotency, audit, outbox) need features of the write store; a store without them cannot hold the records in the same transaction, so startup refuses instead of a request failing later.

Fix: configure a write store whose driver provides the feature, or opt the route out of the default that needs it

SANDLOOM-B011

a non-transactional store is injected into a route with defaults

Slug: underived-store-write · kind: boot · status: active

What happened and why it matters

A store without transactions cannot commit with the request unit, so its writes would carry none of the route's derived guarantees.

Fix: write through the route's write store and project into the other store, or declare the reviewed opt-out

SANDLOOM-B012

a projection breaks its convergence contract

Slug: projection-contract · kind: boot · status: active

What happened and why it matters

Projections converge only under the model of spec §6.4: per-aggregate versions, one owning source group per column, full-state events, and delta events only where the durable inbox makes them exactly once.

Fix: give the target the conditional_write feature, start the key with tenant_id, let one source group own each column, and use delta mappers only for an in-database projection under the durable inbox

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B013

an invalid allow_multi_db_write declaration

Slug: multi-store-waiver · kind: boot · status: active

What happened and why it matters

allow_multi_db_write is the reviewed best-effort 1PC opt-out (spec §4.4 rule 4): secondaries commit first and the write store last.

Fix: list only secondary databases the route actually writes, never the write store itself, and give a reason

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B014

a money effect sits in an opted-out secondary store

Slug: money-effect-outside-write-store · kind: boot · status: active

What happened and why it matters

An opted-out secondary has no atomicity and no loss guarantee; a money effect there could commit without its dedupe record or be lost.

Fix: move the money effect into the route's write store

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B015

an invalid Event class

Slug: event-definition · kind: boot · status: active

What happened and why it matters

Event classes are checked when they are defined and registered at startup: the channel must be versioned, a channel has one producer and the producer lives in the module that owns the channel (spec §5.2). An event payload is plain data from a closed set of wire shapes, checked at every depth when the class is defined: str, int, finite float, bool, None, UUID, datetime, date, time, timedelta, Decimal, an Enum, a Literal, Any (an opaque JSON value); X | None; unions of Literal / Enum members none of which reads another's published value; list, tuple, set / frozenset of scalars and dict[str, ...]; pydantic BaseModel DTOs; unions of DTOs only discriminated by a Literal tag field (Field(discriminator="kind")). Refused, with the field path and the fix: a union of scalars whose members read the same value (int | str, UUID | str, float | int: use one type, or a discriminated DTO), an undiscriminated union holding a DTO or a container (add a discriminator), a callable discriminator, a DTO overriding init or new (use @model_validator(mode="after")), mode="before" / "wrap" validators over a DTO and mode="plain" validators (use mode="after"), custom serializers (SerializeAsAny included) and computed fields, a wire name that does not round-trip (serialization_alias or validation_alias alone, AliasChoices, AliasPath, an empty one: use Field(alias=...)), two fields with one wire name (or a field named like another's wire name where the model reads by name), a model_config option that changes the published JSON or its reading (ser_json_*, val_json_bytes, val_temporal_unit, coerce_numbers_to_str, regex_engine, use_enum_values, polymorphic_serialization, json_encoders, json_schema_mode_override, allow_inf_nan=True, or any option the closed set does not list: drop it), a float that may be NaN or infinite (a DTO's needs allow_inf_nan=False), an excluded field without a default, dataclasses, TypedDicts, NamedTuples, RootModels, unparametrised generics, and non-JSON scalars (bytes, URLs, paths: use str). Each refused shape breaks the reader: it ignores a newer producer's key at every depth (the same .vN may gain an optional field) and must return what a constructed event holds, with the same value and type. The names channel, meta, publish and wire_schema are the Event API and are refused as field names at class definition. A field may carry a wire name (Field(alias=...)): publish() emits the alias, the exported schema names it, and every consumer, view or projection reads it back by the alias. The wire-schema annotation "x-sandloom-default" is the framework's mark of a field with a default (the compatibility check trusts it): a json_schema_extra that sets it, on a field or a nested model, is refused.

Fix: use a versioned channel (<module>.<event>.vN), fields from the closed set of wire shapes (the error names the field path and the allowed alternative), one producer class per channel, defined in the producing module; rename a field called channel, meta, publish or wire_schema, or keep the wire key with an alias: via: str = Field(alias="channel")

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B016

an invalid @on consumer

Slug: consumer-definition · kind: boot · status: active

What happened and why it matters

@on(E) registers through register_event_consumer with all its options; a handler whose parameter is not E, a duplicate consumer name or a tenant field that does not exist on E is refused (spec §5.4).

Fix: take exactly the event as the only parameter, use a unique name per module, and bind the tenant from a UUID tenant field

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B017

a billing atom has no of= subject

Slug: authz-subject-missing · kind: boot · status: active

What happened and why it matters

Subscribed, SubscriptionActive, Credits, Feature and WithinLimit take a required of=: a default could check another billing account than the one the route means (owner decision 1, spec §7.2).

Fix: state the account: of=Account.user(), of=Account.org(param=...) (a route under /orgs/{...}) or of=Account.bound_tenant()

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B018

an invalid @contract class

Slug: contract-definition · kind: boot · status: active

What happened and why it matters

Contract providers are stateless classes registered with register_service; the same class serves local and remote calls (spec §5.3).

Fix: keep the provider stateless (no init, no instance attributes), make members async, give every @command an idempotency_key, and inherit only from bases inside the module package

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B019

a token issuer, intent or money route has the wrong idempotency guard or an intent route a request session

Slug: capability-replay · kind: boot · status: active

What happened and why it matters

A token or a bearer URL must never be replayed from storage: those routes re-run authorization on every retry (mode X) and the effect's own record keeps the effect single (spec §4.6, §4.7). A money route never relies on a replay-only or fail-open guard: an ambiguous COMMIT would re-run the effect (the check that replaced the class-F one, ADR-0009).

Fix: give token-issuer and operation-intent routes an explicit idempotency_guard(scope=..., fail_closed=True, store_response=False) (mode X); an intent route takes no request-session dependency; a money route (sensitive("money")) uses durable=True or that mode X guard

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-B021

a selected driver or capability name has two providers

Slug: driver-conflict · kind: boot · status: active

What happened and why it matters

Drivers are found by name: <capability>.<driver> in the sandloom.drivers entry-point group. Python gives installed packages no order, so a name with two providers has no defined winner. Startup refuses a name INFRA selects when two packages publish it, or when a package publishes the name of a built-in driver. A built-in cannot be replaced silently: another behaviour is another name.

Fix: uninstall all but one package; to change a built-in's behaviour, publish another driver name and select it in INFRA

More: docs/infrastructure/write-a-driver.md

SANDLOOM-B022

a selected driver or capability cannot be used

Slug: driver-load-failed · kind: boot · status: active

What happened and why it matters

INFRA selected a driver or capability whose package failed to import, whose entry point is not a subclass of the capability's contract (or not a CapabilitySpec), which declares no contract_version, or which was written for another major version. A driver that cannot load cannot serve, so startup stops with the cause. An unselected package is never imported.

Fix: fix or reinstall the package named in the message, or select another driver

More: docs/infrastructure/write-a-driver.md

SANDLOOM-B023

an experimental capability is configured in production

Slug: experimental-capability-refused · kind: boot · status: active

What happened and why it matters

An experimental capability's contract may change in any release. Running one in production is a weakening, so the deployment accepts it by name and every start reports it.

Fix: remove it from INFRA, or accept the risk with SANDLOOM_ACCEPTED_RISKS=["experimental_capability"]

More: docs/reference/configuration.md

SANDLOOM-B020

a requested module name has two providers (two installed packages, or an installed package and a local module)

Slug: ambiguous-module · kind: boot · status: active

What happened and why it matters

A module's name keys its tables, migration chain, channels and contracts: the name of its sandloom.modules entry point for an installed package, else the last segment of its import path. SANDLOOM_MODULES may name a module by either, so one name must have one provider. The loader resolves the list before anything loads and refuses a requested name that two installed distributions register, or that an installed package and an app-local module (app.modules.identity next to sandloom-identity) both carry. The message names both providers. An unrequested duplicate is reported only when an application asks for it.

Fix: uninstall the package or remove (rename) the local module; with two installed packages, uninstall one of them

More: docs/modules/index.md

Runtime errors

SANDLOOM-R001

a tenant-scoped operation ran with no tenant bound

Slug: missing-tenant · kind: runtime · status: active

What happened and why it matters

TenantScoped models fail closed: a read, update or delete without a bound tenant raises instead of returning every tenant's rows. The message names the model and the route that hit it.

Fix: add a TenantScope() parameter to the endpoint (after authentication), use bind_tenant(id) in workers, or unscoped() for system work

SANDLOOM-R002

db(), an event publish or record_audit ran outside a Unit of Work

Slug: unit-of-work-required · kind: runtime · status: active

What happened and why it matters

db() returns the session of the innermost open unit; outside any unit there is none. The outbox row commits with the business change; without a transaction there is nothing to commit with, so the guarantee cannot hold and the call raises. required=False (with a # sandloom: best-effort marker) is the explicit lossy form of an effect.

Fix: call it inside a request handler, a contract method, an @on consumer or a task; elsewhere open a unit with async with open_uow(...) (sandloom.sdk.data)

SANDLOOM-R003

an event was published on a channel with no registered schema

Slug: event-schema-missing · kind: runtime · status: active

What happened and why it matters

Every versioned channel needs a schema in every profile (dev = prod). See SANDLOOM-C017 for the static check.

Fix: declare an Event class for the channel in the module's events.py

More: docs/reference/event-contracts.md

SANDLOOM-R004

an event payload does not match its channel's schema

Slug: event-payload-invalid · kind: runtime · status: active

What happened and why it matters

publish_event validates the payload inside the transaction, so an off-schema event never reaches the outbox.

Fix: fix the payload (every violation is listed) or roll the channel to a new version when the shape changes

More: docs/reference/event-contracts.md

SANDLOOM-R005

a contract is neither provided locally nor bound remotely

Slug: service-unavailable-in-deployment · kind: runtime · status: active

What happened and why it matters

core.get_service raises 501 ServiceUnavailableInDeployment; see SANDLOOM-C019.

Fix: declare it in consumes= (the loader then reports it at startup), bind it in SERVICE_BINDINGS, or degrade explicitly

SANDLOOM-R006

an endpoint raised an unhandled exception

Slug: unhandled-exception · kind: runtime · status: active

What happened and why it matters

Production answers an opaque 500 internal_error. The test client re-raises the original exception (type, message, fix) instead, so the assertion shows the cause; tests that assert a 500 opt out with @pytest.mark.sandloom_allow_server_errors.

Fix: read the original exception shown with this error

SANDLOOM-R007

a second unit of the request tried to write

Slug: multi-store-write · kind: runtime · status: active

What happened and why it matters

One writer per request: the first unit that writes becomes the writer and a write by any other unit (later, concurrent, nested or in a child task) is refused before it reaches the cursor (spec §4.4).

Fix: do all of the request's writes in its write store unit; a second store is written only under allow_multi_db_write(...)

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-R008

a unit, read scope or connection started where the unit rules forbid it

Slug: nested-or-concurrent-unit · kind: runtime · status: active

What happened and why it matters

Nested committing units, overlapping units in an operation-intent route, and any unit, read scope or checkout during an external_call() or release_check() are refused, so no transaction spans a PSP call or the authority re-read (spec §4.4 rule 5, §4.6). A window also refuses to open (503, Retry-After) after a remote contract call of the request got no answer from its provider for an attempt (a timeout, a transport failure): its provider unit may still be running. Retry the request with the same Idempotency-Key; the command replays or waits for the running one.

Fix: finish the open unit before starting another (or before external_call / release_check); open no unit while an external call or the release check runs

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-R009

a money effect ran without an effect dedupe record

Slug: money-effect-without-dedupe · kind: runtime · status: active

What happened and why it matters

ADR-0009 item 11: a money effect must be single by its own record, not only by the request's idempotency key.

Fix: key the effect with its own dedupe record (ledger key, intent key, command log) in the same unit

More: docs/adr/0009-route-policy-and-session-freshness.md

SANDLOOM-R010

a sensitive write was called without an Idempotency-Key (428)

Slug: idempotency-key-required · kind: runtime · status: active

What happened and why it matters

A money write whose effect has no dedupe record answers 428 without an Idempotency-Key, so a retry can never repeat it (spec §4.2).

Fix: send an Idempotency-Key header with the request

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-R011

a security-sensitive route needs a fresh authentication (401, RFC 9470)

Slug: step-up-required · kind: runtime · status: active

What happened and why it matters

security routes require a recent authentication; the 401 carries the RFC 9470 challenge (spec §4.2, ADR-0009).

Fix: re-authenticate (step-up) and retry with the new token

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-R012

an operation-intent route answered 2xx outside the two release cases, or release_check() lacked the authority

Slug: intent-release-unchecked · kind: runtime · status: active

What happened and why it matters

The release rule is enforced at response time: a 2xx that matches neither the issue case nor the issued-retry case is discarded, so a bearer URL never leaves without the re-check. An organization account needs the authority re-read (spec §4.6).

Fix: call release_check(account, authority=...) after the last external call, then commit exactly one unit with one write (issue), or return the stored value without an external call (issued retry)

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-R013

db() (or the unit's session) was used from a task that does not own the unit

Slug: concurrent-session-use · kind: runtime · status: active

What happened and why it matters

db() returns the session of the innermost unit and refuses a task that does not own it (complete rule). Passing a session object to another task is unsupported; session methods, sync_session methods, attribute assignments and cursor executions from such a task are detected on a best-effort basis (spec §4.3).

Fix: open the child task's own unit: async with open_uow() as session:

More: tests/fixtures/specs/public-api-1.0-spec.md

Test-harness and snapshot gates (sandloom test)

SANDLOOM-T016

the admin console's manifest is stale or cannot be compiled

Slug: admin-manifest · kind: test · status: active

What happened and why it matters

The admin console draws its screens from <app>/shared/admin-manifest.json, compiled from the admin.py declarations of every composed module and checked against the application's OpenAPI. sandloom test fails when the file differs from what the current declarations and routes compile to.

Fix: run sandloom generate; if it reports a declaration, fix that admin.py

More: docs/admin-console/add-your-module.md

SANDLOOM-T015

module context or declared architecture inspection cannot use its inputs

Slug: inspection-inputs · kind: test · status: active

What happened and why it matters

Context and graph read a generated index without booting the app. Missing/changed layouts, unsupported schemas, unsafe files, incomplete wire definitions or mandatory content over budget fail without partial output. JSON clients use typed diagnostic kinds. Exit 3 means strict source verification is unavailable. Generation explicitly writes files; review its diff. A verified source basis is not live topology.

Fix: read the diagnostic kind; select valid inputs, adjust the budget or explicitly run sandloom generate

More: docs/toolchain/module-context.md

SANDLOOM-T001

a module's routes.snapshot.json does not match its routes

Slug: route-snapshot · kind: test · status: active

What happened and why it matters

Each module commits routes.snapshot.json: method, path, auth (public or authenticated) and the policy object of every route it mounts. The diff is the review of a route's exposure and policy. Kernel-mounted routes (ops endpoints, the platform console's /api/v1/platform/<module>/*) are decided by the kernel and are not part of module snapshots.

sandloom generate refreshes an L1 module's snapshot, but never over a WEAKER policy (authentication removed or the route made public, a risk removed, idempotency removed / no longer fail-closed / no longer durable, the session check weakened, the automatic audit dropped, step-up removed or relaxed, an authorization dependency removed): it fails before writing anything. Review the change, then approve it explicitly with sandloom routes snapshot --write, which names each weakening it approves; the snapshot diff is the record. sandloom test only checks (a stale snapshot: run sandloom generate).

Fix: run sandloom routes snapshot --write and review the diff (it is the approval of every route's operation class)

More: docs/reference/routes.md

SANDLOOM-T002

a table or index is not created by its owning migration chain

Slug: migration-chain · kind: test · status: active

What happened and why it matters

The test harness builds tables from the models, production from the chains. The owning chain of a table is the chain whose revisions create it; a table no chain creates, or an index missing from its chain, passes the suite and breaks production. The check reads the revisions' source: a revision names each table and index literally (or a table through a module-level string constant), as sandloom migrate new writes it; a name built at runtime, such as an f-string in a loop, is not recognised.

Fix: add a revision to the owning module's chain: sandloom migrate new <module> "<message>" (row-level security included)

SANDLOOM-T004

a module's contracts.snapshot.json does not match its contracts

Slug: contract-snapshot · kind: test · status: active

What happened and why it matters

Each module commits contracts.snapshot.json: the describe document of every sync contract it provides. sandloom generate writes it after comparing it with the committed one (a breaking change fails and nothing is written); sandloom test only checks. Callers compare a bound provider's live describe with it (rolling upgrades); sandloom contracts check --against <snapshot> reports breaking changes.

Fix: run sandloom generate and review the diff; a breaking change is a new contract version (.v2), not an edit of .v1

SANDLOOM-T003

a module is not deployed in a deploy profile (warning)

Slug: module-not-deployed · kind: test · status: active

What happened and why it matters

A module absent from deploy/compose/microservices.yml is unreachable there; this is reported as a warning, not a failure.

Fix: sandloom add deploy <module> --service <name> writes the service, gateway route and bindings together

More: deploy/README.md

SANDLOOM-T005

a breaking change to an event schema on the same .vN

Slug: event-schema-compat · kind: test · status: active

What happened and why it matters

Compatibility is checked against the committed snapshot before sandloom generate writes anything, and by sandloom test (spec §5.2, §5.8), at every depth and in both directions (producers and consumers of one .vN deploy independently: a new reader accepts what an old producer sends, an old reader what a new producer sends). Any changed validation constraint is breaking: a narrowed one (a raised minimum, a new pattern) rejects an old producer's payload, a loosened one (a longer maxLength, a removed bound) a new producer's; so is a removed field, open model or not.

Fix: add only optional fields to a published channel; anything else is a new version (.v2, see the event-versioning guide)

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-T006

the models differ from the head of their migration chain

Slug: migration-drift · kind: test · status: active

What happened and why it matters

sandloom test compares the module's models with its chain head (spec §5.6).

Fix: run sandloom migrate new <module> "<message>" and review the revision

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-T007

a generated contract stub is out of date

Slug: contract-stub-stale · kind: test · status: active

What happened and why it matters

Contract stubs in the shared DTO package are written by sandloom generate and never hand-edited; sandloom test fails on a stale one (spec §5.3, §5.9).

Fix: run sandloom generate to regenerate it (a compatible change) or make an incompatible change a new contract version

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-T008

the public API surface or a reference block changed

Slug: public-api-baseline · kind: test · status: active

What happened and why it matters

public_api_baseline.json records every public name and signature (spec §2.1 rule 5, ledger gap G-KERNEL-11). Today the public-api pre-commit hook runs the same gate without this code.

Fix: review the change and refresh public_api_baseline.json (uv run python tools/packaging/check_public_api.py --update)

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-T009

a module imports an advanced sandloom.sdk name without a marker

Slug: beginner-tier · kind: test · status: active

What happened and why it matters

Generators and examples teach the 29 beginner names (spec §2.2), and an application's modules keep to them: the schema & deploy step of sandloom test reports an advanced import that has no marker.

Fix: use the beginner-tier name, or mark the line # sandloom: advanced <reason>

More: tests/fixtures/specs/public-api-1.0-spec.md

SANDLOOM-T010

a module with periodic tasks has no beat in a deploy profile

Slug: periodic-tasks-need-a-beat · kind: test · status: active

What happened and why it matters

A schedule (setup_periodic_tasks in <module>/tasks.py) runs only where a celery beat loads the module, and exactly one may: none and the task never runs, two and it runs twice. sandloom test's static schema & deploy step checks every deploy profile.

Fix: give the module's service its worker and beat: sandloom add deploy <module> --service <name> for an undeployed module, sandloom add task <module> <name> --periodic "<cron>" or sandloom add deploy --refresh for a deployed one (each writes <service>-worker / <service>-beat); in a hand-written profile, add one beat per broker and a worker consuming it

More: deploy/README.md

SANDLOOM-T011

the route dispatch index disagrees with the linear scan, or one bucket holds half the routes

Slug: route-index · kind: test · status: active

What happened and why it matters

The app's router scans only the routes whose leading literal path segments (up to MAX_DEPTH) can match the request. Dispatch must be identical to Starlette's linear scan; a bucket that holds most routes makes the index pointless for the paths it serves (performance, not correctness).

Fix: a disagreement is a kernel bug in sandloom.core.http.route_index (report it); an oversized bucket means routes of several modules share a literal prefix longer than route_index.MAX_DEPTH (a kernel change) or one module owns half the app's routes under one prefix (give it distinct literal prefixes)

SANDLOOM-T012

a plain index duplicates the leading column(s) of a unique key

Slug: redundant-index · kind: test · status: active

What happened and why it matters

A unique index on (a, b) serves every lookup a plain index on (a) would, so the plain one is pure write cost. sandloom test's static schema & deploy step reports it (sandloom.core.testing.static_checks), with no database.

Fix: drop index=True / the Index from the model and add a revision that drops it (sandloom migrate new <module> "<message>"); the TenantScoped tenant_id index is dropped for you when a unique key leads with tenant_id, so keep per-tenant keys tenant-first

SANDLOOM-T013

a TenantScoped table has a key without tenant_id (warning)

Slug: tenant-natural-key · kind: test · status: active

What happened and why it matters

A key of a per-tenant table without tenant_id is unique across tenants: the same user in two organizations collides, the tenant-filtered read misses the other tenant's row and the insert fails on the key (a consumer dead-letters). sandloom test's static schema & deploy step warns; a globally unique id is safe, which no static check can tell.

Fix: lead the key with tenant_id (UniqueConstraint("tenant_id", ...), or a surrogate id primary key); a key that is unique across tenants on purpose (a UUID aggregate id of another module, an operation key) carries info={"sandloom_global_unique": "<reason>"}

SANDLOOM-T014

the microservices profile breaks a split invariant

Slug: split-profile · kind: test · status: active

What happened and why it matters

deploy/compose/microservices.yml must hold what the framework's own profile holds: shared settings identical in every process (one CACHE_URL), signing material on the token issuer only, a database and a migrate one-shot per service, the durable bus, SERVICE_BINDINGS exactly the contracts each service consumes from another one (+ the issuer's remote claims), RPC_ALLOWED_CALLS derived from them, its own RPC keys per service, liveness never fed by peers, and a gateway that routes every route a service's app serves (root-level / and /{slug} included: location = / and the catch-all location /) to that service and never /_sandloom, /diagnostics or /metrics. sandloom test's static schema & deploy step checks it (sandloom.core.testing.deploy_profile); sandloom add deploy writes a profile that passes, and refuses two services claiming the same route prefix, / or the root-level parameter paths (deploy those modules together, or give them distinct prefixes).

Fix: sandloom add deploy --refresh re-derives the generated blocks (services, bindings, RPC_ALLOWED_CALLS, databases, gateway routes) from what the modules provide and consume; a problem outside them (a shared setting set per service, an issuer-only key elsewhere) is fixed by hand in x-common-env / the service's anchor

More: docs/operate/split-into-services.md