# 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`](<#sandloom-t016>) | `admin-manifest` | test | the admin console's manifest is stale or cannot be compiled | active |
| [`SANDLOOM-T015`](<#sandloom-t015>) | `inspection-inputs` | test | module context or declared architecture inspection cannot use its inputs | active |
| [`SANDLOOM-C001`](<#sandloom-c001>) | `module-boundaries` | check | a business module imports a sibling business module | active |
| [`SANDLOOM-C002`](<#sandloom-c002>) | `framework-imports` | check | import outside the framework public API | active |
| [`SANDLOOM-C003`](<#sandloom-c003>) | `raw-provider` | check | provider escape hatch (raw\_client / raw\_backend / raw) in module code | active |
| [`SANDLOOM-C004`](<#sandloom-c004>) | `infra-imports` | check | infra-internal module, legacy global or backend SDK imported | active |
| [`SANDLOOM-C005`](<#sandloom-c005>) | `kernel-handles` | check | kernel handle reached by attribute in module code | active |
| [`SANDLOOM-C006`](<#sandloom-c006>) | `uow-boundary` | check | db.commit() / db.rollback() in module code | active |
| [`SANDLOOM-C007`](<#sandloom-c007>) | `best-effort-effects` | check | an event publish or record\_audit downgraded to best-effort without a marker | active |
| [`SANDLOOM-C008`](<#sandloom-c008>) | `get-core-await` | check | get\_core(request) called without await | active |
| [`SANDLOOM-C009`](<#sandloom-c009>) | `endpoints-outside-api` | check | HTTP endpoint outside the module's api/ layer | active |
| [`SANDLOOM-C010`](<#sandloom-c010>) | `orm-leakage` | check | ORM model / SQLAlchemy in an endpoint file | active |
| [`SANDLOOM-C011`](<#sandloom-c011>) | `schema-orm-imports` | check | schemas/ imports ORM models or SQLAlchemy | active |
| [`SANDLOOM-C012`](<#sandloom-c012>) | `contract-dtos` | check | a contract DTO the generator cannot copy, or a hand-edited / stale generated copy | active |
| [`SANDLOOM-C013`](<#sandloom-c013>) | `test-imports` | check | module test imports outside its whitelist | active |
| [`SANDLOOM-C014`](<#sandloom-c014>) | `tenant-scoped-models` | check | a per-tenant table is not TenantScoped | active |
| [`SANDLOOM-C015`](<#sandloom-c015>) | `unsafe-tenant-sql` | check | runtime raw SQL / Core table access can bypass the tenant guard | active |
| [`SANDLOOM-C016`](<#sandloom-c016>) | `route-operation-class` | check | a route declares no ADR-0003 operation class (retired) | retired (never reused) |
| [`SANDLOOM-C017`](<#sandloom-c017>) | `event-schema-channels` | check | an event is published on a channel the module registers no schema for | active |
| [`SANDLOOM-C018`](<#sandloom-c018>) | `guard-order` | check | a guard or tenant-dependent gate is declared before what it needs | active |
| [`SANDLOOM-C019`](<#sandloom-c019>) | `consumed-contracts` | check | get\_service names a contract the module does not declare in consumes= | active |
| [`SANDLOOM-C020`](<#sandloom-c020>) | `db-dependency-scope` | check | Depends(get\_db) without scope="function" | active |
| [`SANDLOOM-C021`](<#sandloom-c021>) | `module-local-imports` | check | a function-local import in a business module, example or template | active |
| [`SANDLOOM-C022`](<#sandloom-c022>) | `untagged-sensitive` | check | an effect that looks sensitive carries no risk tag (warning) | active |
| [`SANDLOOM-C023`](<#sandloom-c023>) | `multi-store-waivers` | check | lists every allow\_multi\_db\_write opt-out for review | active |
| [`SANDLOOM-C024`](<#sandloom-c024>) | `unsafe-tenant-query` | check | a raw query on a non-relational store outside unscoped() | active |
| [`SANDLOOM-C025`](<#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`](<#sandloom-c026>) | `contract-union-hint` | check | a contract signature uses an ambiguous union (information) | active |
| [`SANDLOOM-C027`](<#sandloom-c027>) | `account-route-mismatch` | check | a route with an organization parameter gates billing with of=Account.user() | active |
| [`SANDLOOM-B001`](<#sandloom-b001>) | `module-load-failed` | boot | a module failed to load | active |
| [`SANDLOOM-B002`](<#sandloom-b002>) | `guard-order-startup` | boot | idempotency\_guard resolves before the route's authentication | active |
| [`SANDLOOM-B003`](<#sandloom-b003>) | `financial-guard` | boot | a class F route used a response-storing or fail-open guard (retired) | retired (never reused) |
| [`SANDLOOM-B004`](<#sandloom-b004>) | `operation-class-contradiction` | boot | a route's operation-class declarations contradicted each other (retired) | retired (never reused) |
| [`SANDLOOM-B005`](<#sandloom-b005>) | `contract-binding` | boot | a required consumed contract is neither provided nor bound | active |
| [`SANDLOOM-B006`](<#sandloom-b006>) | `module-registration` | boot | a module's registration is invalid | active |
| [`SANDLOOM-B007`](<#sandloom-b007>) | `gate-order-startup` | boot | an authz gate that needs an organisation runs before TenantScope() | active |
| [`SANDLOOM-B008`](<#sandloom-b008>) | `route-policy` | boot | a route is neither authenticated nor public(), is both, or gives no reason | active |
| [`SANDLOOM-B009`](<#sandloom-b009>) | `write-store-ambiguous` | boot | a route injects several writable sessions without writes() or an opt-out | active |
| [`SANDLOOM-B010`](<#sandloom-b010>) | `guarantee-unsupported` | boot | the write store lacks a feature the route defaults need | active |
| [`SANDLOOM-B011`](<#sandloom-b011>) | `underived-store-write` | boot | a non-transactional store is injected into a route with defaults | active |
| [`SANDLOOM-B012`](<#sandloom-b012>) | `projection-contract` | boot | a projection breaks its convergence contract | active |
| [`SANDLOOM-B013`](<#sandloom-b013>) | `multi-store-waiver` | boot | an invalid allow\_multi\_db\_write declaration | active |
| [`SANDLOOM-B014`](<#sandloom-b014>) | `money-effect-outside-write-store` | boot | a money effect sits in an opted-out secondary store | active |
| [`SANDLOOM-B015`](<#sandloom-b015>) | `event-definition` | boot | an invalid Event class | active |
| [`SANDLOOM-B016`](<#sandloom-b016>) | `consumer-definition` | boot | an invalid @on consumer | active |
| [`SANDLOOM-B017`](<#sandloom-b017>) | `authz-subject-missing` | boot | a billing atom has no of= subject | active |
| [`SANDLOOM-B018`](<#sandloom-b018>) | `contract-definition` | boot | an invalid @contract class | active |
| [`SANDLOOM-B019`](<#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`](<#sandloom-b021>) | `driver-conflict` | boot | a selected driver or capability name has two providers | active |
| [`SANDLOOM-B022`](<#sandloom-b022>) | `driver-load-failed` | boot | a selected driver or capability cannot be used | active |
| [`SANDLOOM-B023`](<#sandloom-b023>) | `experimental-capability-refused` | boot | an experimental capability is configured in production | active |
| [`SANDLOOM-B020`](<#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`](<#sandloom-r001>) | `missing-tenant` | runtime | a tenant-scoped operation ran with no tenant bound | active |
| [`SANDLOOM-R002`](<#sandloom-r002>) | `unit-of-work-required` | runtime | db(), an event publish or record\_audit ran outside a Unit of Work | active |
| [`SANDLOOM-R003`](<#sandloom-r003>) | `event-schema-missing` | runtime | an event was published on a channel with no registered schema | active |
| [`SANDLOOM-R004`](<#sandloom-r004>) | `event-payload-invalid` | runtime | an event payload does not match its channel's schema | active |
| [`SANDLOOM-R005`](<#sandloom-r005>) | `service-unavailable-in-deployment` | runtime | a contract is neither provided locally nor bound remotely | active |
| [`SANDLOOM-R006`](<#sandloom-r006>) | `unhandled-exception` | runtime | an endpoint raised an unhandled exception | active |
| [`SANDLOOM-R007`](<#sandloom-r007>) | `multi-store-write` | runtime | a second unit of the request tried to write | active |
| [`SANDLOOM-R008`](<#sandloom-r008>) | `nested-or-concurrent-unit` | runtime | a unit, read scope or connection started where the unit rules forbid it | active |
| [`SANDLOOM-R009`](<#sandloom-r009>) | `money-effect-without-dedupe` | runtime | a money effect ran without an effect dedupe record | active |
| [`SANDLOOM-R010`](<#sandloom-r010>) | `idempotency-key-required` | runtime | a sensitive write was called without an Idempotency-Key (428) | active |
| [`SANDLOOM-R011`](<#sandloom-r011>) | `step-up-required` | runtime | a security-sensitive route needs a fresh authentication (401, RFC 9470) | active |
| [`SANDLOOM-R012`](<#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`](<#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`](<#sandloom-t001>) | `route-snapshot` | test | a module's routes.snapshot.json does not match its routes | active |
| [`SANDLOOM-T002`](<#sandloom-t002>) | `migration-chain` | test | a table or index is not created by its owning migration chain | active |
| [`SANDLOOM-T004`](<#sandloom-t004>) | `contract-snapshot` | test | a module's contracts.snapshot.json does not match its contracts | active |
| [`SANDLOOM-T003`](<#sandloom-t003>) | `module-not-deployed` | test | a module is not deployed in a deploy profile (warning) | active |
| [`SANDLOOM-T005`](<#sandloom-t005>) | `event-schema-compat` | test | a breaking change to an event schema on the same .vN | active |
| [`SANDLOOM-T006`](<#sandloom-t006>) | `migration-drift` | test | the models differ from the head of their migration chain | active |
| [`SANDLOOM-T007`](<#sandloom-t007>) | `contract-stub-stale` | test | a generated contract stub is out of date | active |
| [`SANDLOOM-T008`](<#sandloom-t008>) | `public-api-baseline` | test | the public API surface or a reference block changed | active |
| [`SANDLOOM-T009`](<#sandloom-t009>) | `beginner-tier` | test | a module imports an advanced sandloom.sdk name without a marker | active |
| [`SANDLOOM-T010`](<#sandloom-t010>) | `periodic-tasks-need-a-beat` | test | a module with periodic tasks has no beat in a deploy profile | active |
| [`SANDLOOM-T011`](<#sandloom-t011>) | `route-index` | test | the route dispatch index disagrees with the linear scan, or one bucket holds half the routes | active |
| [`SANDLOOM-T012`](<#sandloom-t012>) | `redundant-index` | test | a plain index duplicates the leading column(s) of a unique key | active |
| [`SANDLOOM-T013`](<#sandloom-t013>) | `tenant-natural-key` | test | a TenantScoped table has a key without tenant\_id (warning) | active |
| [`SANDLOOM-T014`](<#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 &amp; 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 &amp; 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 &amp; 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`

## Related reading

- [CLI reference](<https://sandloom.dev/docs/reference/cli/index.md>)
- [Getting help](<https://sandloom.dev/docs/getting-started/help/index.md>)
