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