Every guarantee Sandloom promises and every capability a user can rely on, with the
tests, sandloom check rules and import contracts that prove it. The ledger
(guarantees.yml) is a CI gate (tests/architecture/test_guarantee_ledger.py): an enforcing test that
disappears, an entry deleted without a removed: record, or a new ADR section
without an entry fails the build.
429 guarantees, 174 capabilities, 37 recorded gaps.
Run exactly the enforcing tests (from the repository root):
An ORM read, update or delete against a TenantScoped model with no tenant bound (and not inside unscoped()) raises MissingTenantError instead of running cross-tenant.
The ORM guard also covers relationship loads (lazy, selectin, joined), joins, EXISTS/IN subqueries, select_from and Session.get (an identity-map hit is re-checked against the bound tenant): unbound they fail closed, bound they return only the bound tenant's rows.
An ORM object insert of a TenantScoped model inherits the bound tenant, fails with MissingTenantError when none is bound, and fails with TenantMismatchError when it names a different tenant_id.
Under a bound tenant, bulk INSERTs, bulk UPDATEs that change tenant_id (values or ordered_values) and bulk-by-primary-key UPDATEs on a TenantScoped model raise UnsafeTenantOperationError; they are only allowed inside explicit unscoped().
Core select/insert/update/delete statements (including a raw Table alias mixed into an ORM query) against a recognised TenantScoped table under a bound tenant are refused with UnsafeTenantOperationError when they cannot be safely scoped.
The explicit helpers scoped(), tenant_filter() and current_tenant_id() raise MissingTenantError when no tenant is bound, and scoped() restricts results to the bound tenant.
Tenant scoping state never leaks between requests: the compiled-statement cache does not capture a tenant, a pooled connection never carries a previous request's tenant criteria, and declaring a new TenantScoped model invalidates the guard's memoised table set.
TenantScope binds the tenant only from the signed access token: an X-Tenant-Id header naming a tenant outside the token's tenant_id/org_id claim or tenant_ids allowlist is refused with 403, and a header inside the allowlist selects that tenant.
A signed tenant_ids claim containing a malformed entry fails every request with 400 'invalid tenant id' (never memoised, no string-membership shortcut).
A required TenantScope with no resolvable tenant answers 400 'tenant context required', a token granting several tenants without an X-Tenant-Id selector answers 400 instead of picking one, and a MissingTenantError reaching the request boundary also answers 400 'tenant context required' (never a bare 500, never the internal message).
TenantScope rejects a revoked access token (401) before selecting a tenant from its claims, so a stale tenant_ids grant cannot keep selecting a tenant after revocation.
With TENANCY_MODE=single, settings refuse to load without SINGLE_TENANT_ID, TenantScope binds the canonical tenant, and an X-Tenant-Id selector or token tenant naming another tenant is refused with 403.
sandloom/core/tests/test_tenant_isolation.py::test_single_tenant_mode_requires_canonical_tenant sandloom/core/tests/test_tenant_isolation.py::test_single_tenant_mode_binds_canonical_tenant sandloom/core/tests/test_tenant_isolation.py::test_single_tenant_mode_rejects_other_tenant_selector gap (S): The 'token tenant does not match the configured tenant' 403 (token claim, no header) branch has no test.
G-TENANT-15
With TENANCY_MODE=single, request UoWs, worker/script UoWs and contract read scopes bind the canonical tenant automatically, and only for their own extent.
sandloom check rule tenant-scoped-models reports every ORM model or Core Table declaring its own tenant_id without TenantScoped, unless listed in tenant_scope_exempt with a written reason; a stale exemption or one without a reason fails.
sandloom check rule unsafe-tenant-sql reports runtime raw SQL in business modules (text(), Model.table, exec_driver_sql, including deferred lambdas/generators) unless it executes inside an explicit unscoped() block; declaration-time defaults are exempt.
sandloom/core/tests/test_review_archcheck.py::test_raw_tenant_sql_requires_explicit_system_scope sandloom/core/tests/test_review_archcheck.py::test_deferred_sql_does_not_inherit_creation_scope sandloom/core/tests/test_review_archcheck.py::test_raw_sql_declaration_defaults_do_not_need_a_runtime_scope tests/architecture/test_archcheck_library.py::test_framework_manifest_passes_every_rule rule unsafe-tenant-sql gap (S): The rule is only tested at the analyzer level (find_unsafe_tenant_sql); no test runs it through run_checks/sandloom check on a module fixture as the tenant-scoped-models rule is.
G-TENANT-18
monotonic_upsert requires a tenant_id in the row values, never overwrites another tenant's row with the same key, and raises TenantMismatchError when the row's tenant_id differs from the bound tenant; monotonic_update is scoped to the bound tenant.
On a tenant_rls database, every SQL path from the app role (ORM, Core via session/connection, text(), exec_driver_sql, streaming cursors, run_sync, executemany/insertmanyvalues, savepoints) sees and changes only rows of the tenant in sandloom.tenant_id, and an INSERT/UPDATE for another tenant is refused by WITH CHECK.
An unset, empty or malformed (not canonical lowercase UUID) sandloom.tenant_id yields zero rows and refused writes on an RLS table, never a cast error or fail-open, including when no engine hook is installed.
sandloom.tenant_id is transaction-local and re-synced by the engine hook: it follows a tenant switch within a transaction, is restored after a savepoint rollback, and never carries one pooled transaction's tenant into the next.
Startup refuses (TenantRLSConfigError, in every environment, aborting aopen_all and Celery worker boot) a tenant_rls database whose application role is a superuser, has BYPASSRLS, is a member of such a role, owns an RLS table directly or by membership, or is covered by an unconditional (system-role) policy.
sandloom/core/tests/test_tenant_rls_bypass.py::test_verification_refuses_superuser_app_role sandloom/core/tests/test_tenant_rls_bypass.py::test_verification_refuses_owner_and_member sandloom/core/tests/test_tenant_rls_bypass.py::test_verification_failure_aborts_startup_in_every_environment sandloom/core/tests/test_worker.py::test_worker_boot_runs_tenant_rls_role_verification gap (S): The BYPASSRLS-attribute branch (and membership in a BYPASSRLS/superuser role) has no direct test; only SUPERUSER, table ownership and system-role membership are exercised.
G-RLS-06
The role check cannot be switched off: the former verify_roles database option is refused at startup, and a hardened NOSUPERUSER NOBYPASSRLS role passes.
The system channel (system_session) reaches other tenants only on tables migrated with enable_tenant_rls(..., system_access=True), stays tenant-scoped on every other RLS table, honours an explicit tenant_id, rolls back on error, and writes an sandloom.system_db.session audit row with actor and reason in the same transaction.
sandloom/core/tests/test_tenant_rls_bypass.py::test_system_channel_reaches_all_tenants_and_is_audited sandloom/core/tests/test_tenant_rls_bypass.py::test_system_session_tenant_scoped_and_rollback gap (S): 'No audit row, no commit' (a failing audit write aborts the system session's commit) and the structured start/end log record (op_id, outcome, duration) are not tested.
G-RLS-09
system_session refuses (SystemSessionRefused) an empty actor or a too-short reason and raises SystemChannelUnavailable for a database with no system channel; require_system_channel without a system DSN, or a system_url without tenant_rls, fails at provider construction.
Business-module code has no path to the system channel: sandloom.core.drivers (system_db) and sandloom.core.sql_alchemy.tenant_rls are forbidden infra imports, absent from the public core API, and the database provider exposes no system engine; the app role cannot SET ROLE to the system role.
A raw asyncpg driver connection bypasses the RLS hook but runs under the transaction's last synced setting, so with no tenant it fails closed (zero rows).
The in-process contract pool installs the RLS hook too, so contract_session() statements are scoped by sandloom.tenant_id on a tenant_rls database (also when tenant_rls is requested after the default contract engine exists).
The no-tenant round-trip-skipping allowlist never weakens RLS: it never skips a bound tenant, clears a tenant set earlier in the transaction, ends with its block, refuses untagged raw SQL or a tenant table inside it (NonTenantStatementError), and in production syncs instead of skipping on a violation.
Residual risk is pinned, not hidden: forging sandloom.tenant_id via set_config under the shared app role reads another tenant (strict xfail), and a superuser bypasses RLS (documented caveat test); a fix that changes either must flip these tests.
A database instance without tenant_rls is unchanged: no hook, no system channel, no I/O at aopen, and the provider does not advertise the tenant_rls feature (it is advertised only when the option is on).
With ENVIRONMENT=prod and PRODUCTION_PROFILE=multi_tenant_strict, boot refuses (enforce_configuration) TENANCY_MODE other than multi, any database instance without tenant_rls, AUTH_REVOCATION_FAIL_CLOSED=false, and a SECURITY_CACHE_INSTANCE that is 'default', undeclared, or on the same Redis server as the default cache — listing every reason.
The strict profile's boot check stops every entry point — ASGI app, Celery worker, sandloom migrate and the sandloom CLI — before it serves or touches anything.
Under the strict profile a critical startup task (API and worker) fails with ProductionProfileError, naming each table and the enable_tenant_rls(op, table) fix, when any TenantScoped table on any database lacks enabled+FORCEd RLS or its
_tenant_isolation policy, or is missing.
sandloom/core/tests/test_production_profile.py::test_rls_coverage_reports_unprotected_tenant_tables sandloom/core/tests/test_production_profile.py::test_startup_enforcement_fails_on_an_uncovered_table gap (S): enforce_runtime is tested directly, but no test proves it is wired as a critical startup task of create_app's lifespan or of the Celery worker boot (removing either call site would stay green).
G-PROFILE-06
Under the strict profile startup attests via INFO server identity that the security cache (and a separate idempotency cache) reach a different Redis process than the default cache, failing with ProductionProfileError on the same process or when identity cannot be read.
sandloom/core/tests/test_infra_review_20261022.py::test_strict_profile_checks_physical_redis_identity sandloom/core/tests/test_idempotency_cache_instance.py::test_strict_runtime_attests_the_idempotency_redis_process gap (S): The 'Cannot attest separate ... Redis processes' failure (deployment_identity raising) has no test.
G-PROFILE-07
Under the strict profile an IDEMPOTENCY_CACHE_INSTANCE set to another instance than the security cache must be declared and on a different Redis server than the default cache (refused otherwise), sharing the security Redis is only a warning, and leaving it unset keeps the previous behaviour.
Security-critical state (revocation marks, blocks, idempotency records) is written through SECURITY_CACHE_INSTANCE, never plain cache(); idempotency records move to IDEMPOTENCY_CACHE_INSTANCE when set while revocation marks stay in the security cache.
Under the strict profile a split deployment (SERVICE_BINDINGS set) is refused with CONTRACT_COMPAT_CHECK=off or a shared HS secret (the accepted risk shared_hs_secret_split is refused), and a process loading no token issuer is refused an HS secret by the kernel role check with no opt-out.
Every framework UoW dependency (DbSession, Database(name), get_db) is declared with scope="function", so the request transaction commits (and its on-commit hooks run) before the response is sent.
Only the UnitOfWorkContract issues COMMIT and only the commit pipeline owners reach its stages; business modules never call db.commit()/db.rollback() (uow-boundary rule).
The commit pipeline runs in the documented order before_commit -> COMMIT -> outbox dispatch -> on_commit, and on_commit callbacks run after the outbox stage (also when there is no outbox stage).
Rolling back a SAVEPOINT (db.begin_nested()) discards the outbox rows, on_commit callbacks and before_commit checks registered inside it, while those of surviving savepoints and the parent stay live.
When several database sessions are active in one scope, publish_event and record_audit without database= raise instead of guessing, and an explicit database= deterministically selects that database's UoW (each database owns its own outbox).
sandloom/core/tests/test_review_fixes_v2.py::test_resolve_scoped_session_ambiguous_without_database sandloom/core/tests/test_review_fixes_v2.py::test_resolve_scoped_session_single_database gap (S): Only the private resolver is unit-tested; no end-to-end test holds DbSession + Database("x") in one endpoint and asserts the event lands in the chosen database's outbox_messages (and is marked delivered there).
G-UOW-07
Two named databases are independent engines with independent UoWs; there is no shared COMMIT across them (no distributed transaction).
sandloom/core/tests/test_infra_multi_database.py::test_two_databases_are_independent_and_both_work gap (S): Non-atomicity itself is a documented non-guarantee; no test pins that a failing second-database UoW leaves the first committed (i.e. that nobody quietly introduces a coupled commit).
G-UOW-08
The default-database UoW binds the single-tenant canonical tenant only inside the UoW and a tenant-scope failure on enter rolls the UoW back.
db() is the session of the innermost open unit of the current context; outside any unit it is SANDLOOM-R002; the first db() in a request creates the lazy request unit (handler task only), committed on success and rolled back on error before the response; a closed unit refuses reuse and never auto-begins (an exited unit's session becomes a ClosedKernelSession: every mutation and I/O entry refuses with SANDLOOM-R002, independent of DB_SESSION_OWNER_CHECKS).
Operation-intent mode: no request unit, sequential units; external_call() and release_check() exclude any unit, read-scope connection or checkout during their lifetime, the window opening atomically with the no-unit check and re-verified after the read-scope drain, so a unit a racing task opens meanwhile is refused (SANDLOOM-R008; the authority callback's read-contract calls excepted); the drain waits for a read contract already running on the scope's shared connection and closes its unit (a window entered from inside that read is SANDLOOM-R008); the window also waits for every contract call of the request already running in its provider unit (command or read; refused from inside one) and refuses a new one while open, so it never runs beside a provider transaction; a REMOTE contract call (the generated RPC proxy) obeys the same rules: refused inside a window before it is sent, counted while it runs so a window entered meanwhile waits for it (local == remote), and a remote call with an attempt its provider never answered (a timeout, a transport failure: its provider unit may still run after the transport gave up) refuses every later window of the request, fail closed (503, SANDLOOM-R008; the client retries with its Idempotency-Key), while an answered failure leaves it open, "answered" meaning its MAC-bound effects were merged (round 5): an answer whose fx the caller never merged - one larger than RPC_MAX_RESPONSE_BYTES (its fx.id lost with it), a reply without a well-formed envelope, malformed fx, or an unkeyed (unauthenticated) refusal - counts as unanswered (round 6: fx is malformed when any field is unknown or any ENTRY of rk / id / sr / ra / su has the wrong type or exceeds its bound - fx.id [None], a 17th id, an unknown risk, a non-positive step-up, an ra other than exactly the integer 1 (round 7: 1.0 and true are malformed) -; its well-formed risks still mark the request, its ra never does), so a root catching that 502 / 503 cannot open a window beside a nested unit that may still run; at any depth: a provider (or a local contract) whose own nested remote call went unanswered reports it back MAC-bound (fx.id) even when it caught the failure and answered OK, and every caller up the chain merges it, so the root's windows refuse too (a replay of that provider's command does not re-raise it: the uncertainty is not a committed effect); a 2xx outside the issue case (at least one external call, release_check after the last, exactly one write after it) or the issued-retry case (no commit after the release) is discarded with SANDLOOM-R012; an organization account needs the authority; db() is SANDLOOM-R002; SANDLOOM-B019 at startup.
Write admission covers the cursor boundary: a write through the session's connection (conn.exec_driver_sql, also under unscoped()) is classified and refused like an ORM or Core write when another unit of the request is the writer.
The write store of a route is resolved statically: a single writable session is the write store; create_app refuses a write route with several writable sessions and neither writes() nor allow_multi_db_write, and a writes() naming a database the route does not inject (SANDLOOM-B009), and an allow_multi_db_write secondary that is the write store or not injected (SANDLOOM-B013).
A money-ledger insert in an opted-out (allow_multi_db_write) secondary is refused (SANDLOOM-B014); the same insert in the route's write store is admitted.
One writer per request: the first unit that writes becomes the writer; a write by any other unit of the request is refused before the cursor (SANDLOOM-R007), unflushed changes at COMMIT included; a read-only parent with a child's sole write is allowed, a child and its parent never both write; declared secondaries (allow_multi_db_write) are admitted, before or after the write store (a secondary is never the writer), and commit first, the write store last, whatever the endpoint's parameter order and however the sessions are nested in dependencies (either order, any depth, a session shared with the endpoint); a later commit failing after a writing unit of the request committed (a partial commit) increments sandloom_multi_store_partial_commit_total{route,store} (the route template, each committed store) and logs a warning naming the route, the committed stores and the failing store, while a request that commits everything or nothing is never counted; outside a request units are unconstrained.
Write admission's stated scope: every SQLAlchemy statement through a kernel session or connection is classified (transaction-control statements are not writes; raw DBAPI is unavailable to modules); contract, consumer, task, saga-step and kernel units run in a fresh admission scope.
create_app refuses (StoreCapabilityError, SANDLOOM-B010) a write route whose write store lacks audit while the route carries a static risk, a route with an explicit durable idempotency guard whose default database lacks http_idempotency, and any database hosting request units whose driver lacks write_admission; the check reads the registry's advertised features without building a provider.
create_app refuses (SANDLOOM-B011) a write route with a writable session on a store without transactions (it cannot commit with the request unit), unless that database is a declared allow_multi_db_write secondary.
Consistency tokens never become an error: SANDLOOM-Consistency-Token is HMAC-SHA256 under PROJECTION_TOKEN_KEYS (the newest kid signs, every listed kid verifies), expires after 600 s, carries at most 16 entries (a larger write gets none) and is ignored when missing, malformed, forged, expired, of an unknown kid or of another tenant; a write route whose pending events feed a projection answers with the token of the rows they produce, each entry naming its source group and aggregate; a read presenting it waits at most wait_ms (PROJECTION_RYW_WAIT_MS) until each entry's own group version column reaches it (never another group's version; a page read rereads its rows every poll round and a relational read reloads the rows its session already holds, so a cached row behind the token catches up within the budget), then serves with SANDLOOM-Projection-Stale: true (on_stale="serve") or reads the entity from its source (on_stale="source").
Projection apply converges: full-state events replace a group's owned fields iff the stored version is NULL or older, so any interleaving with duplicates ends at the max-version event per group, on a relational model and on a conditional_write store alike (an independent group's upsert never clears another group's tombstone); every column has exactly one owning group (SANDLOOM-B012); delta mappers are accepted only for a relational projection under the durable inbox (exactly once by the inbox) and refused on a store target.
Tombstones are versioned and beat any late older upsert; only a newer write of the group that owns deletion clears one (never another group's upsert, on a relational model and a store target alike, as the conditional_write conformance kit checks); they are purged only by purge_tombstones(name, older_than=) / sandloom projection purge, which refuses an older_than below the replay horizon (stream retention + relay horizon, the oldest DLQ entry and the oldest dead outbox row of the source channels; never with an unbounded retention) and while any rebuild runs (running rebuilds are counted, so one finishing never lifts the exclusion of another; a rebuild's entry and a purge's rebuild check, watermark raise and delete are mutually exclusive - the purge window, a per-projection transaction advisory lock the relational begin_rebuild takes too, an asyncio lock in memory - so a rebuild that starts after the purge read no rebuild waits until the delete is done, and a purge after a begun rebuild is refused), raises the monotonic purged_before watermark before deleting (a rebuild never lowers it), and from then on the apply path dead-letters an event emitted before the watermark (fail closed without emitted_at); the apply''s watermark check and its write are atomic with respect to a purge (the apply''s unit holds the watermark row FOR SHARE, created when missing, until it commits; the raise row-locks it), so a purge racing an apply never resurrects a row, on a relational model and a store target alike.
A store projection is tenant-native: each conditional write names its row's tenant, a row whose tenant is missing or differs from the bound tenant is refused, and store reads return only the bound tenant's rows: a read key or filter naming another tenant never overrides the bound one (it reads nothing, fail closed) and a store read without a bound tenant is refused.
Outside a UoW scope publish_event (and publish_event_versions) raises UnitOfWorkRequiredError; only required=False publishes straight to the bus (at-most-once), and required=False inside a UoW still uses the outbox.
In module code every best-effort downgrade (publish_event / publish_event_versions / record_audit with required=False) must carry a '# sandloom: best-effort ' marker or sandloom check fails.
publish_event preflights channel name, payload schema and payload size before the event is written, so an event the bus would reject is refused to the caller instead of being committed to the outbox.
sandloom/core/tests/test_infra_hardening.py::test_preflight_rejects_oversized_payload sandloom/core/tests/test_infra_hardening.py::test_preflight_rejects_missing_schema sandloom/core/tests/test_strict_mode.py::test_publish_missing_schema_raises_in_every_profile sandloom/core/tests/test_event_bus_schemas.py::test_validation_failure_lists_every_violation sandloom/core/tests/test_uow_outbox.py::test_publish_event_best_effort_outside_uow_is_preflighted sandloom/core/tests/test_guardrails.py::test_event_payload_size_guard gap (S): No test calls publish_event INSIDE a UoW with an invalid channel / schema-violating / oversized payload and asserts the call raises and no outbox_messages row exists; the in-UoW path (unit_of_work/effects.py preflight) is only covered indirectly.
G-OUTBOX-05
The dispatched envelope id equals the outbox row id and stays the same across post-commit dispatch and relay redeliveries.
A failed post-commit dispatch never fails the committed operation: the row stays pending and the relay delivers it later (rows are marked delivered iff delivered; a crash between publish and mark leads to redelivery, never loss).
On the request / consumer / contract paths (wait=False) post-commit dispatch is a hand-off to a background coalescer that does not wait for the broker; db_session()/Celery/scripts (wait=True) publish in line; queue overflow falls back to the relay.
The relay retries a failed row with capped per-row exponential backoff, and a row exhausting OUTBOX_RELAY_MAX_ATTEMPTS is dead-lettered (dead_at set, outbox_dead_total) instead of retried forever; a dead row can be requeued to pending.
A broker outage burns no relay attempts: it trips a cool-down circuit (OUTBOX_RELAY_CIRCUIT_*) that skips relay ticks, so an outage never dead-letters healthy rows.
Concurrent relays and post-commit marks settle every row exactly once: relays partition the backlog by lease, a settle never touches a reclaimed lease, and a late mark never resurrects a dead-lettered row.
The relay retention purge deletes only delivered outbox rows older than OUTBOX_RETENTION_HOURS (never pending or dead rows), on a time cadence within a time budget.
Events carry NO ordering guarantee, not even within one channel or aggregate; consumers order by a per-aggregate version applied with version guards, so redelivered or reordered events never roll state back. (The relay holding a channel's later rows behind an earlier row waiting to retry is an implementation detail, not a guarantee: one poison row delays its channel until it is dead-lettered.)
OUTBOX_RELAY_ENABLED=off in a production app process is refused unless the accepted risk relay_disabled is declared, and under gunicorn exactly one elected worker runs the relay (others delegated).
on_commit callbacks run only after the UoW commits, never on rollback, never twice; a failing callback is logged and never fails the committed request; outside a UoW the callback runs at once.
before_commit checks run after the handler, in registration order, right before COMMIT; the first failing check rolls the UoW back and propagates; outside a UoW the check runs at once; the testing run_on_commit seam runs before_commit checks first.
sandloom/core/tests/test_before_commit.py::test_checks_run_in_order_after_the_handler_and_before_commit sandloom/core/tests/test_before_commit.py::test_a_failing_check_rolls_back_instead_of_committing sandloom/core/tests/test_before_commit.py::test_outside_a_unit_of_work_the_check_runs_at_once sandloom/core/tests/test_before_commit.py::test_testing_run_on_commit_runs_the_before_commit_checks_first gap (S): No test proves that a before_commit check registered inside a rolled-back savepoint is skipped (ADR §3 stage 1).
G-EFFECT-04
All durable dedupe stores (consumer_inbox, contract_command_log, http_idempotency_log) are adapters of one relational claim implementation (sandloom.core.sql_alchemy.dedupe_log): a claim is decided by the in-flight transaction, a rolled-back claim leaves no record, the claim never flushes the caller's pending objects, and the bounded purge removes only due records.
Every replay record shares one set of bounds from sandloom.core.effects / replay_limits: replayed body/result at most 256 KiB (larger -> non-replayable marker, never re-run) and a 24 h default TTL; argument digests are canonical.
durable_inbox(consumer).claim(db, event_id) inserts with ON CONFLICT on UNIQUE(consumer, event_id) in the handler's own UoW: the first claim returns True, a duplicate returns False, and a handler's own constraint violation is never read as a duplicate.
A command contract's dedupe record is claimed in the provider's own UoW and commits iff the effect commits, so a retry after a crash following COMMIT replays the stored result and never re-executes.
A failed command leaves no dedupe record (the retry runs afresh), and a provider killed mid-command holds no reservation (its uncommitted claim dies with the connection, no TTL stall). This includes an earlier attempt of the same call still in flight (attempt 1 unanswered, attempt 2 answered with a failure, attempt 1 then runs afresh and may succeed), the semantics of a client retry after a failure; the unanswered attempt keeps the caller's request in doubt (G-UNIT-02).
A command result larger than 256 KiB is stored as a non-replayable marker: a repeat raises ContractResultNotReplayable (409) and the method is never re-run.
A command record expires after CONTRACT_COMMAND_DEDUPE_TTL_SECONDS (the key then runs afresh by taking over the expired record), an in-flight claim past its TTL is never taken over, and purge never deletes a concurrently taken-over record.
Without a database command log (no command_idempotency feature) command dedupe falls back to the cache, fail-closed: a cache outage raises ContractIdempotencyUnavailable (503) and the method does not run; the fallback follows IDEMPOTENCY_CACHE_INSTANCE.
A retry while the first request is pending gets 409; the pending reservation has a short TTL that is renewed while the request runs and self-heals if the request dies.
After a failed attempt (error response or unhandled exception) the key is released so a retry reserves afresh; the error response is sent before the release.
Every cache-guard transition is a compare-and-set on the reserving request's owner token: an expired owner can neither release nor finalise over a retry's reservation.
Idempotency keys are scoped per caller and tenant (never replayed across tenants), and an anonymous caller never gets a stored body replayed (status only).
A response larger than the capture cap is returned in full but stored as a non-replayable success marker (never re-executed, body not preserved); a corrupt record is discarded instead of blocking the key.
idempotency_guard(durable=True) claims its record in the request's DbSession UoW and commits it with the response: a retry replays the stored response and the effect runs once; a rolled-back request leaves no record; a returned 4xx is replayed; a returned 5xx is never replayed.
Durable guard records expire after the guard TTL (taken over then purged); oversized/streamed responses replay status only; a route whose response was not captured is refused rather than re-run; routes added after create_app are captured too.
A valid token's revocation and block state is checked live before an idempotency reservation or replay, even when the guard precedes the endpoint's auth dependency.
Cache-guard records live in SECURITY_CACHE_INSTANCE unless IDEMPOTENCY_CACHE_INSTANCE moves them (with task-enqueue dedupe and the command cache fallback); revocation marks never move, and the strict profile refuses an idempotency instance that is not its own durable Redis.
The automatic guard (user writes carrying Idempotency-Key, with no explicit guard, no_idempotency or operation intent) always replays the status only, for a DbSession write and for an ordinary write through db() (the lazy request unit holds the durable record) (mode S: Idempotent-Replay: true, empty body, the handler never runs again), including after an ownership transfer; replay_safe(reason=) opts a non-sensitive route in to body replay (mode B); a sensitive write is always S; the replay is decided after every route-level dependency (session check, gates, step-up); a different request under the same key is 422, and cross-route or cross-tenant reuse never replays.
A write route without a request unit, or whose request unit's database keeps no durable request log, gets the fail-closed in-flight form of the automatic guard: a concurrent duplicate is 409, a cache outage 503, and nothing is stored.
enqueue(..., idempotency_key=) publishes one real message for successful retries (task id replayed within dedupe_ttl_seconds), including parallel retries; the replay window expires after the TTL.
Enqueue dedupe fails closed: an unavailable dedupe store prevents the publish (503), and an ambiguous broker send keeps the key pending (503 task_enqueue_uncertain, never republished) until reconciled; deterministic send/serialisation errors release the reservation.
run_async runs task coroutines on one persistent event loop per worker process (fresh after fork, engines disposed on it at shutdown) and refuses to run inside a running loop.
Celery tasks store no result by default (task_ignore_result, also on driver-built apps, enqueue subscribes no result consumer), the worker prefetches one message, and acks_late stays a per-task opt-in.
guarantee_window_problems reports command_dedupe_below_retry_horizon when CONTRACT_COMMAND_DEDUPE_TTL_SECONDS is below the relay redelivery horizon (OUTBOX_RELAY_MAX_ATTEMPTS x capped backoff); defaults satisfy every window.
Production refuses to start on a violated guarantee window (GuaranteeWindowError naming the risk id), other environments warn, and a window is waived only by an accepted risk registered in accepted_risks.
sandloom outbox purge --inbox-days D refuses D < EVENT_STREAM_RETENTION_SECONDS + 1 day (any D when retention is unbounded) unless --force (risk inbox_purge_below_redelivery).
A consumer registered by a module gets the durable group :: equally named consumers of two modules (or of one module in two services) get distinct groups and each receives every event, while replicas of one service share the group.
Startup fails when two consumers resolve to one group in a process, when an explicit group= is reused by another module, or when the group is already owned by another module / database (sandloom:group-owner fingerprint); consumer names are unique across event and projection consumers.
Required consumers (the default) abort startup when they cannot connect (all consumers are still attempted, transient failures retried within CONSUMER_STARTUP_TIMEOUT_SECONDS), and /ready fails while a required live subscription is lost; required=False consumers never delay startup or readiness.
Each consumer delivery runs async (session, envelope) in a UoW: commit on success; a handler error leaves the delivery for redelivery (rollback).
sandloom/core/tests/test_event_consumers.py::test_start_event_consumers_wires_and_delivers sandloom/core/tests/test_streams_event_bus.py::test_failed_handler_leaves_message_for_redelivery sandloom/core/tests/test_inmemory_bus_delivery.py::test_failing_consumer_is_retried_until_success gap (S): No test asserts the handler's DB writes are rolled back when the handler raises (the consumer UoW rollback itself); tests check redelivery with fake sessions only.
G-EVENT-06
A consumer with dead_letter=False retries a failing delivery forever with capped backoff instead of dead-lettering it, while default consumers are dead-lettered after the bus's delivery budget.
The Streams transport redelivers unacked entries, reclaims dead consumers' pending entries via XAUTOCLAIM, dead-letters an entry to .dlq after exactly the max deliveries, and acks the source only after the DLQ append succeeded.
Re-registering an identical event schema is a no-op, while a breaking change (new required field or removed required field on the same version) fails registration / module load.
Durable/required consumers are refused on the volatile Pub/Sub bus unless the volatile_event_bus risk is accepted, and the memory/redis Pub/Sub drivers are refused in production without it.
record_audit writes the audit_log row in the current UoW, so it commits iff the business change commits.
sandloom/core/tests/test_audit_and_secrets.py::test_record_audit_writes_in_uow sandloom/core/tests/test_revocation_freshness.py::test_block_write_outage_is_503_and_rolls_back_audit sandloom/core/tests/test_fused_effect_rows.py::test_outbox_and_audit_rows_are_one_statement gap (S): No generic test that a plain record_audit followed by a UoW rollback leaves no audit_log row; only the block_user path proves it.
G-AUDIT-02
record_audit defaults to required=True: outside a UoW, with a non-UUID actor_id, or without an audit store it raises AuditUnavailableError; required=False is skipped silently (best-effort, marker required in module code).
sandloom/core/tests/test_audit_and_secrets.py::test_record_audit_outside_uow_raises_by_default sandloom/core/tests/test_audit_and_secrets.py::test_record_audit_best_effort_outside_uow_is_skipped sandloom/core/tests/test_audit_and_secrets.py::test_record_audit_default_rejects_invalid_actor sandloom/core/tests/test_testing_harness.py::test_required_audit_without_uow_raises rule best-effort-effects gap (S): The 'no audit store' branch of AuditUnavailableError (database driver without the audit feature) has no direct raising test (test_audit_store_skips_unsupported_backend only checks the store is absent).
Every framework guard and the single bearer parser (bearer_claims) reject a JWT whose token_type is not "access" (e.g. a refresh token or a token without token_type), so it can never act as an access token.
JWT_ALGORITHM accepts only HS256/HS384/HS512, EdDSA, RS256 or ES256 (none and anything else fail settings validation); each asymmetric algorithm is bound to one key type at boot (wrong key type, RSA < 2048 bit, wrong EC curve refuse startup) and the HS and asymmetric key families are mutually exclusive.
In asymmetric mode verification accepts exactly JWT_ALGORITHM with the one public key named by the token's kid: a token without kid, with an unknown kid, of another algorithm, HMAC-signed with the public PEM or with JWT_SECRET is rejected.
Startup role check (critical, the process refuses to serve): an issuer (a process loading a module with issues_tokens=True) without JWT_PRIVATE_KEY, or a verify-only process holding JWT_PRIVATE_KEY or JWT_SECRET, is refused; in prod a split process on a shared HS secret is refused unless the accepted risk shared_hs_secret_split is set, and never under multi_tenant_strict.
A verify-only process (asymmetric mode, no private key) cannot mint access tokens; in the microservices split every identity-minted access token is EdDSA under the issuer's kid and verifies in processes holding only public keys.
Symmetric key rotation: secrets in JWT_PREVIOUS_SECRETS still verify but never mint, a token whose kid equals JWT_KID verifies with JWT_SECRET only, and in prod previous secrets obey the JWT_SECRET strength rules and may not contain the current secret.
Asymmetric key rotation without forced logout: every kid in JWT_PUBLIC_KEYS verifies, a retired (removed) kid is rejected at once, and startup refuses a JWT_PRIVATE_KEY that is not the private half of JWT_PUBLIC_KEYS[JWT_KID].
A claims contributor (core.add_claims_contributor) runs in its module's own UoW and is auto-exposed as .claims.v1; a failing critical contributor, local or remote, aborts minting with ClaimsUnavailable (503), the first critical failure in registration order winning.
Every minted token carries claims_sources / claims_sources_failed; a module's claim is trusted only when that module is in claims_sources and not in claims_sources_failed, otherwise the fact is unknown and escalates to a live check.
Identity stamps auth_time (epoch seconds of the primary sign-in) and carries it unchanged through refresh; recency (step-up) checks read auth_time, never iat, so a refreshed token with a fresh iat is not 'recent'.
Password hashing is argon2id from PASSWORD_HASH_PROFILE (strong default m=65536/t=3/p=4, owasp_min m=19456/t=2/p=1, custom requiring every value); explicit PASSWORD_HASH_* values override the preset and the configured hasher follows settings.
Production refuses password-hash parameters below the OWASP argon2id minimum unless the accepted risk weak_password_hash is set (outside prod only a warning).
A successful login re-hashes a hash with other parameters (upgrade or downgrade) in the same UoW; a failed login never re-hashes and a re-hash failure never blocks the login (counted).
A failed login does the same work (one argon2 pool submission against a startup-warmed dummy hash, same cache/SQL steps) whether or not the e-mail has an account, so neither response nor timing reveals account existence.
TOTP secrets are encrypted at rest with TWOFA_ENCRYPTION_KEY (comma list, newest first, for rotation); in prod with identity loaded the key is mandatory and must be strong (critical startup refusal), only outside prod an unset key derives from JWT_SECRET.
An access token is revoked iff its issue instant iat_us (microseconds) is <= its subject's revocation mark; a token without iat_us is revoked by any mark; every framework guard rejects a revoked token with 401 'Token has been revoked'.
The mark is written at now + AUTH_REVOCATION_CLOCK_SKEW_SECONDS, so a token from an issuer whose clock runs ahead is still revoked, while an issuer that ran the issuance check (ensure_user_not_blocked) and tokens minted later in the revoking request are stamped past it.
The revocation mark is written with the cache's atomic set_max and never moves backwards; concurrent revocations of one subject all succeed and end at the maximum; a security cache with neither set_max nor cas is refused for revocation writes.
require_immediate=True writes the mark before COMMIT and, if that write fails, refuses the change with 503 (RevocationUnavailableError) and rolls it back; after commit a second mark max(mark, commit time + skew) revokes tokens minted between the early mark and the commit, and a commit slower than the skew also revokes the request's own tokens (counted auth_revocation_remark_overtook_floor_total).
Every bundled security event revokes the subject's tokens with require_immediate=True: tenancy member removal/leave, role change, ownership transfer (both parties), org deletion; identity role revocation, password change/reset, deactivation; block_user.
sandloom_tenancy/tests/test_role_change_revocation.py::test_role_change_revokes_the_members_tokens sandloom_tenancy/tests/test_role_change_revocation.py::test_ownership_transfer_revokes_both_parties sandloom_tenancy/tests/test_security_hardening.py::test_member_removal_and_leave_revoke_the_users_tokens sandloom_tenancy/tests/test_security_hardening.py::test_org_deletion_revokes_every_members_tokens sandloom_identity/tests/api/test_security_api.py::test_role_revocation_is_immediate_on_identity_endpoints tests/integration/test_revocation_pipeline.py::test_a_immediate_removal_needs_no_applier_after_a_crash gap (S): No test pins that password change/reset and user deactivation use the immediate (pre-commit) path specifically, rather than only that tokens end up revoked.
G-REVOKE-07
Revocation is at-least-once: core.auth.tokens_revoked.v1 is written to the outbox in the same UoW, the post-commit mark write is retried AUTH_REVOCATION_WRITE_ATTEMPTS times, a still-failing write is counted (auth_revocation_deferred_total) and identity's durable consumer sessions@tokens_revoked applies it idempotently without ever lowering a newer mark.
The revocation applier is registered dead_letter=False: an outage is retried forever with capped backoff (never parked in the DLQ), only a malformed payload is dead-lettered at once, and while a revocation is stuck /ready answers 503 degraded with /health unaffected.
The mark lives AUTH_REVOCATION_MARK_TTL_SECONDS (default max(access TTL + 120 s, 24 h), never shorter than the access lifetime); a token older than the verifier's mark TTL is rejected (fail closed on lifetime drift) and /ready fails on ACCESS_TOKEN_EXPIRE_MINUTES drift between services.
A subject-state record (sandloom:auth:subject:{id}) that exists but is not a valid value rejects every token of the subject on every authenticated route (one live GET, counted); the issuance check repairs it to now + skew, never deleting or lowering a readable record, and a revocation overwrites a corrupt record.
On a revocation-store outage AUTH_REVOCATION_FAIL_CLOSED decides (unset = closed in prod, open elsewhere): closed answers 503 on every authenticated route (the block cannot be confirmed, no copy answers); open accepts with a warning.
Revocation is strict on every authenticated route: each request reads the subject-state record live with one GET, so a revocation applies on the very next request in every process; several guards on one request share the one lookup.
before_commit(callback) runs after the handler right before COMMIT and a raising check rolls the unit back; the commit-time re-check (subject_commit_check) runs before the COMMIT of every committing unit of the request, automatically on writes and writing GETs and under at_commit() for explicit commit obligations (replacing revocation_at_commit()); it re-reads the caller's subject-state record live there, so a revocation or block written during the request answers 401 with nothing committed (also for named-database units).
While the commit-time re-check is armed, internal RPC sends described read methods unchanged and propagates remote commands (and methods of unknown kind) with the re-check in the MAC-covered context (rc: sub, iat_us; the provider re-checks in its own unit before COMMIT) instead of refusing them, so no remote command commits unchecked; every HTTP request (incl. nested ASGI and internal RPC) starts a fresh authority context and restores the caller's on return.
Every authenticated route reads the subject-state record sandloom:auth:subject:{id} live, with one GET memoised per request (no route-level freshness setting, no per-process copy); a revoked or blocked token answers 401 WWW-Authenticate: Bearer error="invalid_token", a fail-closed outage 503.
The commit-time re-check runs before the COMMIT of every committing unit of an armed request: automatically on user writes (a write or a queued effect commits), on a GET whose unit wrote (its on_commit kept), and on every unit under at_commit(); units that commit nothing skip it; a foreign revocation between two commits of one request refuses the second; operation-intent routes use release_check.
Self-revocation is "check, then revoke": a unit revoking its own subject commits after the live check passed; a foreign revocation landing before the self-revoking call is still seen; a self-revocation inside a rolled-back savepoint contributes nothing; committed units carry the exemption to later units of the request, a remote command's included: the acknowledgement crosses out as a signed caller fact (usr.sr) and the provider's re-check skips the subject exactly as a local unit's does; over RPC the provider's self-revocations come back MAC-bound (sr); a replay of the self-revoking command (the same caller's retry, locally or the proxy's automatic retry) acknowledges it alike (kept with the command's dedupe record).
A remote command under an armed re-check is propagated in the MAC-covered RPC context (rc: sub, iat_us) and the provider re-checks before its own COMMIT (instead of the caller refusing to send it); described remote reads are sent unchanged.
The block is a value of the subject-state record (v1:blocked:, no TTL), read live on every authenticated request, never from a per-process copy, and rejects every token of the user, including ones minted after the block, with 401 'Account blocked' (WWW-Authenticate: Bearer error="invalid_token", as G-REVOKE-21).
block_user must run in a unit of work with a reason, writes a required audit (auth.user.blocked) and writes the block value of the subject-state record LAST (after audit, immediate revocation and event); a cache outage raises BlockUnavailableError (503) and the audit rolls back.
unblock_user (audited auth.user.unblocked) replaces the block value of the subject-state record with after: by compare-and-set only after its unit of work commits (on_commit); a rolled-back or failed unblock leaves the user blocked.
Every bundled token issuer (password login, password+2FA login, SSO and SSO second-factor completion, refresh exchange) calls ensure_user_not_blocked live before any side effect (no token, no refresh rotation, SSO state not consumed), answering 401 'Account blocked'; a cache outage follows AUTH_REVOCATION_FAIL_CLOSED.
sandloom_identity/tests/api/test_emergency_block.py::test_blocked_user_cannot_login_or_refresh sandloom_identity/tests/api/test_emergency_block.py::test_blocked_user_cannot_complete_sso_or_sso_2fa sandloom_identity/tests/api/test_emergency_block.py::test_blocked_user_cannot_login_with_password_2fa sandloom_identity/tests/api/test_emergency_block.py::test_issuer_block_check_on_cache_outage gap (M): No generic check forces a custom (app-defined) issuer to call ensure_user_not_blocked; the rule is documentation-only for third-party issuers.
G-BLOCK-05
On commit block_user ends the user's sessions: all access tokens issued so far are revoked and core.auth.user_blocked.v1 is published, whose identity consumer revokes every refresh token, so lifting the block requires a fresh login.
One subject-state record: v1:blocked: (no TTL) outranks every v1:after:, so a revocation never unblocks; the Redis writer is one Lua script, the portable writer a compare-and-set loop that refuses to run on a store without CAS.
Unblock replaces blocked with after: by compare-and-set after commit and never deletes the record: tokens issued before the block stay revoked, and a rolled-back unblock keeps the block.
The platform route policy is pinned: each module's routes equal its committed routes.snapshot.json (format 3: the full resolved policy per route and method) and the kernel-mounted routes carry the kernel's policies, so adding, removing or changing the resolved policy of a route fails CI; billing's routes are exactly the approved set, declare no freshness budget and are always strict.
create_app prunes the route-policy markers (public, no_idempotency, replay_safe, sensitive, operation_intent, writes, allow_multi_db_write) from route.dependant (never executed per request) while keeping the declaration readable; responses, OpenAPI and session behaviour are unchanged, and a marker bound to a parameter of another dependency is never pruned.
Identity admin-gated RBAC / user-admin mutations re-check the actor inside the request unit (admin_in_transaction: RBAC advisory lock + actor admin assignment and active user row FOR SHARE): a token still claiming admin after revocation gets 403, a concurrent demotion serialises, and two admins demoting each other do not deadlock.
Tenancy admin-gated writes authorizing on the acting member (role change, removal, org update, invitation create/revoke) lock the actor row FOR SHARE and the target FOR UPDATE in the write's unit, in user_id order: a demotion/removal committed first is seen (403/404), an action committed first serialises the demotion, no deadlock.
create_app refuses (RoutePolicyError, SANDLOOM-B008) an API route that neither resolves an authenticator (@authenticates, user or kind="service") nor declares public(reason=...), one that does both, an empty reason, or two different public reasons; the error names the fix. Apps assert the same deny-by-default with sandloom.core.testing.route_policy(route).
public(reason=) is recorded with its reason; token issuers (public(issues_tokens=True, reason=...)) keep the live issuance check; a router-level public() covers its routes; the bundled public routes are exactly the reviewed set.
PSP webhooks are public(reason=...) routes, not service principals, and verify their signature inside the handler (verification unchanged: an invalid or missing signature is rejected).
Each module's routes.snapshot.json (format 3) records the static resolved policy per route and method: auth, effect, session, risk with its sources, idempotency (mode), audit, step_up_max_age_s, public_reason, issues_tokens, operation_intent, write_store, multi_db_write, and authz (the custom authorization dependencies marked with sandloom.sdk.web.authorizes, in resolution order); drift, including a weakening such as a dropped risk, fails the module's tests with the fix (SANDLOOM-T001). sandloom generate never writes a weaker policy over the committed snapshot, and sandloom test never writes any generated file (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): sandloom generate fails with SANDLOOM-T001 before anything is written, names sandloom routes snapshot --write, and sandloom test fails, until sandloom routes snapshot --write approves it explicitly, naming each approved weakening (the snapshot diff records it). Idempotency is compared by its properties (present, fail-closed, durable), derived from every mode the snapshot writes (none / none (reason), automatic S and automatic B (reason): a durable fail-closed record; explicit durable / cache / in-flight with or without fail-closed: only the durable kind is durable); losing any property is a weakening whatever the spelling (automatic S to explicit in-flight, fail-closed loses durability), and an unknown mode is never accepted silently.
The money / security effects a module's own tests see on a route are recorded as INFORMATIONAL observed_effects in its routes.snapshot.json: the route runtime hands each request's risk effects to the test harness's observer (none in production; a failing observer never fails a request), the sandloom.sdk.testing plugin records them per module when SANDLOOM_TEST_OBSERVE_EFFECTS is set (sandloom test outside --ci), sandloom routes snapshot --write attaches them, and the snapshot check ignores them in either direction while a real policy change still fails.
The framework's admin gates carry the admin risk statically: require_platform_admin, require(Role("admin")), require(OrgRole("admin"|"owner")) (and an Or of them), tenancy's require_org_role("admin"|"owner") and the platform console's operator gate; member gates and Not do not.
Money risk is declared at its source: @command(..., risk="money") (the L2 contract_method(command=True, risk="money")) (recorded in the describe document and the contract snapshot; billing credits.spend), the MoneyLedger mixin (a validated NOT NULL + UNIQUE operation key; billing's credit ledger; an INSERT in a request is a money effect) and @sensitive_effect("money") (billing intents).
Security risk: identity's credential services (password change, 2FA init / enable / disable / backup-code regeneration, SSO link / unlink) are @sensitive_effect("security", step_up=300); mark_reauthenticated() after the current-password check (or a recent auth_time) satisfies it, so identity's behaviour is unchanged. The step-up is enforced identically when the effect runs in a contract command served over RPC: the signed caller facts carry the end user's auth_time and the request's re-authentication to the provider, whose call runs in a request scope built from them (with or without a commit-time re-check context, on an operation-intent route too), so stale auth is 401 and fresh auth or a re-authentication passes, local and remote alike; a re-authentication or a pending step-up the remote execution leaves comes back with its response.
Every sensitive write gets exactly one automatic, mandatory audit row (sandloom.sensitive_write) in the request's writing unit: actor, route template, method, path parameters, risks with their sources and the route's status; never the body; no row for a read or a refused request; a money command marks the caller's request alike locally and remotely (from the describe document before the call, and from the MAC-bound risks of the response: every risk the remote execution observed, its nested calls' included, so an untagged command calling a money command marks the request as it would locally; the risks travel on error responses too, merged before the caller raises, so a wrapper that raises after its nested money command committed still marks the request; a command REPLAY, local or remote (the proxy's automatic retry after a lost answer included), marks the caller exactly like its first execution: the durable command record (and the cache fallback) keeps the risks the execution observed and the replay re-applies them, its on_commit callbacks' included (round 5: the cache record is written after the callbacks ran; the durable record commits with the body's effects plus what each queued callback declares - a @sensitive_effect callback's risk and step-up, fail closed - and is corrected with what the callbacks actually left once they ran, a closure's included; a process death between the commit and that correction replays the declared callbacks' risks and nothing of a closure; round 6: the correction - and the cache record - is written on EVERY exit after the commit, a cancelled callback (a request timeout) included, with what the callbacks left until then plus what the queued ones declare when they did not all finish (fail closed), in a task shielded from the caller's cancellation (anyio cancels again at every await), bounded (5 s) and never raising into the caller: a closure that completed a money effect before its request was cancelled is on the record, so a later replay owes the audit too)), so the audit does not change after a split or a retry; such a command commits in its own unit, as does a risky effect run inside any contract unit (locally too), so the request writes its row even when its own units wrote nothing (the route runtime opens the request unit when the handler opened none).
Money dedupe is mandatory: a money effect needs a dedupe record (its own operation_key, the request's request_operation_key(), a command key or a ledger key); a client without Idempotency-Key gets 428 (SANDLOOM-R010); a route that cannot dedupe the key it got is SANDLOOM-R009 (a route whose only money effect is a command is held to the same rules: the command key is its dedupe record); a sensitive("money") write committing without either is refused before COMMIT; at startup a money route whose idempotency guard is the response-storing cache form or fail-open is refused (SANDLOOM-B019), while durable=True or store_response=False + fail_closed=True guards are accepted.
Step-up (RFC 9470): an effect declaring step_up=N (security: 300 s) needs auth_time (never iat) at most N s old or mark_reauthenticated() in the request, else 401 WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age=N (SANDLOOM-R011); checked at entry, at the effect's return and before the writing unit's COMMIT; a sensitive("security") route refuses before the response, reads included.
Risk is a join: Requirement.risks() is the union over And / Or and empty under Not; a route's resolved risk is the union of its sources (admin gates, @sensitive_effect dependencies, sensitive() markers) whatever their order or repetition, each naming its sources; the derived step-up is the strictest of its contributors' (obligations are a homomorphism).
Authorization decisions are three-valued (allow / deny / indeterminate) and keep their status: a definite Subscribed/Credits denial (of= an explicit Account) is 402, an unregistered or unavailable fact or a missing contract is 501, an unreachable owner is 503; an indeterminate branch never yields allow through Or/Not.
require(...) evaluates the cheapest tier first (claims -> resource -> live) and short-circuits; a claims-only policy such as require(Subscribed(of=Account.user())) opens no DB session and does no I/O.
SubscriptionActive(of=...) and Credits(n, of=...) (of= required) are live and authoritative, resolved through the owner's contract (local, or the SERVICE_BINDINGS RPC proxy when remote).
Registering an access fact auto-exposes .facts.v1; a process without the owner that binds it in SERVICE_BINDINGS gets the same decision as the monolith, with the declarative ClaimRule travelling over RPC (describe, cached).
Org-scoped billing facts take the organization from the path or the bound tenant, never from the user id; without an org context the decision is indeterminate and a missing org can never be negated into allow.
Subscribed, SubscriptionActive, Credits, Feature and WithinLimit require of=; a missing or non-Account value raises AuthzSubjectMissingError (a TypeError, SANDLOOM-B017) naming the three choices (Account.user(), Account.org(param=), Account.bound_tenant()).
Account.org(param=) resolves the path, then the query parameter, else X-Tenant-Id, else the bound tenant, validated as before; no organization, or the caller's own personal tenant, is indeterminate and never the caller's user id; Account.user() is the caller's own account on any route. Pinned by a property over random route shapes and bindings.
Account.bound_tenant() resolves by tenant kind: personal is the user, an organization confirmed by the membership contract is the org, canonical / unknown / unbound is indeterminate; tenant_kind() is authoritative only and memoised per request.
Feature / WithinLimit with an organization account read the owner's organization.features / organization.limits facts live (501 without a registered fact); with Account.user() they read the claims as before.
The authorization algebra is unchanged (spec §8.3), pinned by properties over random And / Or / Not trees of fixed-outcome, evaluation-counting atoms against an independent model of today's rule: the value, the atoms evaluated and their order (cheapest tier first, short-circuit, no extra evaluation), fail-closed soundness (an allow holds under every two-valued completion of the indeterminate atoms), Not(U) = U, Or allowing on a genuine allow beside an indeterminate, evaluate agreeing with decide; the known order dependence (~(F & U) allows, ~(U & F) does not) is kept.
create_app refuses (GuardOrderError) a route where FastAPI resolves an idempotency_guard before its authenticating dependency; router-level auth counts as preceding, and a custom authenticator counts once marked @authenticates.
sandloom/core/tests/test_guard_order.py::test_guard_in_route_dependencies_before_param_auth_is_refused sandloom/core/tests/test_guard_order.py::test_guard_param_before_auth_param_is_refused sandloom/core/tests/test_guard_order.py::test_custom_authenticator_counts_once_marked sandloom/core/tests/test_guard_order.py::test_bundled_app_routes_pass gap (S): Tests call the order checker directly; no test boots create_app with a misordered route to prove the startup wiring.
G-GUARD-02
A valid access token's revocation and block state is checked before an idempotency_guard reserves a key or replays a stored response, even when the guard is listed before the endpoint's auth dependency.
The sliding-window rate limiter is always enforced in prod (RATE_LIMIT_ENABLED only forces it elsewhere), blocks boundary bursts, and a 429 carries Retry-After; on a cache outage it fails open by default or answers 503 when configured fail-closed.
The global per-IP floor (RATE_LIMIT_GLOBAL_REQUESTS) is off at 0 by default, when set limits every route including unknown routes / 404s per IP, and fails open on a cache outage.
X-Forwarded-For is honoured only with TRUST_PROXY_HEADERS=true and an immediate peer inside TRUSTED_PROXY_IPS, walked right-to-left skipping trusted hops (duplicate XFF lines combined in wire order); with trust on but no CIDRs prod uses the peer and dev the rightmost hop; invalid CIDRs fail settings.
/metrics, /diagnostics/, /contracts/ and /health details are closed (404) without a valid INTERNAL_API_TOKEN in every environment, opened only by INTERNAL_ENDPOINTS_OPEN_IN_DEV outside prod; METRICS_TOKEN unlocks /metrics only.
Public /health and /ready return check statuses only (module/consumer topology hidden without X-Internal-Token) and are cached for HEALTH_CACHE_TTL_SECONDS so anonymous traffic cannot fan out probes.
Checkout and customer-portal run as operation_intent routes, authorize on get_member_state (role + membership version) and commit a pending operation intent BEFORE any PSP call, keying the PSP call with the intent id (sandloom:intent:): an owner outage at authorize creates nothing (503), a PSP timeout leaves the intent pending (503) and a retry reuses the same PSP session, a definitive PSP 4xx fails the intent (502).
Right before the issue commit the final re-read, wrapped in release_check, checks the membership state (role lost 403, other change 409, owner unreachable fail closed) and the caller's subject-state record (401); any refusal revokes the pending intent (audited), expires the checkout session and never returns the URL (a portal URL is never handed out).
The F-route authorization is serializable, not linearizable: a tenancy change committed before the final re-read is refused, one committed between the final re-read and the issue COMMIT is not seen (the acknowledged, pinned residual window).
Intent lifecycle: every transition (issue, revoke, fail, reconciler) is guarded by WHERE status='pending'; issued, revoked and failed are terminal — no retry, refusal, PSP error or reconciler run changes an issued intent or replaces its session.
Operation-intent route retries never hand out a bearer URL without fresh authorization: the guard stores no response (store_response=False); an issued checkout retry re-runs the re-read (release_check) and returns the stored URL without a PSP call (403 and no URL after authority loss, 410 past the session expiry); an issued portal retry is 409.
Intent ownership is enforced at application level on every path: retry lookup by actor + operation + key hash then customer and params digest; issue/close/reconcile writes by id + customer_id (+ actor) + status; the webhook gate resolves the customer from the PSP customer and requires the intent's own session.
The webhook applies a credit grant or a new subscription only for an object naming an issued intent of that customer; a completion for a revoked/pending/failed/unknown intent is not applied, audited (billing.intent.unauthorized_completion) and flagged for an operator; objects without the marker (pre-intent sessions) apply as before.
A subscription or credit completion tied to an intent must match the intent's price with exactly one item of quantity 1, and one intent yields at most one subscription.
The periodic reconciler (beat, acks_late) closes a pending intent older than BILLING_INTENT_RECONCILE_AFTER_MINUTES by its PSP marker: no session -> failed, open session -> revoked + expired, completed -> revoked + followup_at; lookup/expire failures are persisted and bounded by BILLING_INTENT_RECONCILE_MAX_ATTEMPTS, then left to an operator.
A failed expire compensation is counted (billing_intent_expire_failed), recorded on the intent and retried by the reconciler; the refusal stands and the webhook gate remains the backstop.
Webhook ownership and ordering: a subscription/invoice change applies only when its PSP customer owns the row, unknown Stripe statuses are refused, incomplete_expired/canceled are terminal and never revived, and a stale update never rolls state back.
A credit-pack checkout grants the catalog price's credits only when the PSP customer and metadata customer_id match, amount_total + currency equal the price and payment_status is paid.
A subscription without an intent marker is synced but grants entitlements only for a catalog price with BILLING_GRANT_INTENTLESS_SUBSCRIPTIONS=true (default false); a subscription to an unknown price grants nothing (price stored NULL, audited).
A portal-driven plan / cancel_at_period_end change with no issued portal intent of that customer in the attribution window is applied but audited billing.subscription.unattributed_change; lifecycle updates are never audited and another customer's portal intent never attributes.
Refunds and disputes of credit packs reverse the granted credits proportionally and cumulatively, once per delivery, only for the grant's own PSP customer; a won dispute restores them.
BILLING_PROVIDER=stripe fails fast without STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET; prod refuses the fake PSP (critical startup task) unless the accepted risk fake_psp_in_prod is set; both deploy profiles require an explicit BILLING_PROVIDER.
The checkout and portal routes are operation_intent routes and take no request unit: each intent step is its own short sequential unit, no transaction is nested in another, and none (nor any held connection, checkout or row lock) spans the PSP call (external_call) or the final re-read (release_check); write admission refuses a unit opened during either (SANDLOOM-R008), including one a racing task opens while the window is entered; a read contract already running on the request's shared read connection finishes and its unit is closed before either window opens, and a contract call already running in its own provider unit finishes before either window opens (a new one is refused while it is open), whether the call is local or remote (RPC); a remote call whose provider left an attempt unanswered (timeout, transport failure, or an answer whose MAC-bound effects the caller could not merge: too large to read, no well-formed envelope, malformed effects, an unkeyed refusal - round 5) may still hold its unit, so neither window opens in that request (fail closed, 503 SANDLOOM-R008), whichever hop made the unanswered call (a provider reports its own nested unanswered calls back, caught or not, and each caller merges them).
billing.credits.v1.spend / get_balance hold a personal account to the end user of the call when the call context carries one (the request's subject locally, the signed caller facts over RPC, carried on by every hop: a provider's own nested calls run for the same end user, with or without a commit-time re-check context, so splitting a wrapper never turns a user call into a system call): another user's id is BillingTenantMismatchError (403); system and service callers (no end user anywhere up the chain) are unchanged. A command replay never bypasses it: the command dedupe record is scoped to the calling end user, so another user reusing a spender's operation key runs the body and gets its own 403, never the stored result (every @command, locally and over RPC); the spender's own retry still replays.
The billing API's account is chosen explicitly: without org_id the personal account answers, also for a user in exactly one or several organizations (no single-organization inference); an organization's account needs org_id.
A credit spend of 0 (a free item: a zero price, a promo code) succeeds and is recorded like any spend: a ledger row with delta 0 under its operation key (a replay returns the same result, the same key with another amount is 409), the spent event, the balance unchanged; an account without a billing customer yet gets one. It succeeds whatever the balance, including a negative one left by a refund or dispute reversal (a positive spend still needs cover). A negative amount is refused (422) and a grant of 0 stays refused.
Every contract call, local or served over RPC, runs in the PROVIDER's own Unit of Work on the provider module's database (never the caller's session, which is restored afterwards); a read contract's transaction is always rolled back and a command contract commits its own UoW.
A read contract that publishes an event, writes an audit/outbox row, queues an on_commit effect, adds/modifies ORM objects or runs raw DML raises ReadContractWriteError (in its own UoW and inside a shared read scope) and its writes never persist.
A command contract must name a mandatory idempotency-key argument at registration, and a call with an empty key is refused (ContractIdempotencyKeyMissing) before any UoW opens.
Contract signatures take no session: a provider method with a session parameter is refused at registration (and by the architecture test), and a session passed by a caller, positionally or by keyword, is 422 ContractArgumentError before any UoW opens, locally and over RPC.
register_service raises ContractTypeError for a provider with a public member that is not a @contract_method, an unannotated or non-wire-type parameter/return, or args/*kwargs; a local provider exposes only its contract methods (private members and unknown names raise AttributeError).
Arguments bind with Python's rules identically on the local and RPC paths: unknown/typo kwargs, missing arguments, positional-only by keyword, keyword-only positionally and arguments failing annotation validation are 422 ContractArgumentError both ways.
Results are encoded once and decoded against the return annotation on both paths, so a local caller receives exactly the types a remote one does (a pydantic model not a dict, an Enum not its value, exact Decimal, UUID, tuple/set/frozenset, non-string map keys), with a stable digest independent of the hash seed; a Contract(REF) caller receives each DTO as the class its generated stub names (the generated copy, G-CONTRACT-19) on both paths, every field set as read off the wire.
SANDLOOM-C012. A business module registering a contract whose pydantic model / Enum (nested included) is a class of ANOTHER module's package, or one defined in a function or main, fails with ContractTypeError naming the DTO, the method position and the fix; a DTO of the provider's own package (the norm: its schemas module) or of a shared package (sandloom.shared.*, CONTRACT_DTO_PACKAGES) is accepted, and sandloom generate writes a copy of each own DTO into the shared DTO package. sandloom check (contract-dtos) reports an own-package DTO a contract signature (an @contract class's public method or a @contract_method) names that has no generated copy. A remote caller that has not imported a DTO's class decodes it into a loaded generated copy of it, else as plain data, and never imports a module named by the peer.
A provider HTTPException or http_status-protocol exception reaches the caller as ContractCallError with the same status, detail, headers (e.g. Retry-After) and code locally AND over RPC; a domain 401/403 stays 401/403 over RPC, never a 503.
An unknown method or contract at a remote provider surfaces at the caller as 501 ServiceUnavailableInDeployment (an unknown method on a local provider is AttributeError).
An AttributeError / ValueError (or any unexpected exception) escaping provider code is a provider failure — 500 locally, 503 RemoteServiceUnavailable code=provider_error remotely — and is never masked as the endpoint's 404 method_not_found or 422 binding error.
At startup every ContractRequirement in ModuleDescriptor.consumes must be provided locally or bound in SERVICE_BINDINGS: a missing required one raises ContractBindingError (startup aborts), a missing optional one only warns, and the shipped modules declare what they consume.
Inside a request read scope LOCAL read-contract calls on one database share one contract-pool connection (READ COMMITTED, always rolled back) that never sees the caller's uncommitted writes; a failing call discards the shared transaction and nested/concurrent calls fall back to their own UoW.
Identical local and RPC read calls (contract, method, bound tenant, arguments) are memoised for the request, each hit is an independent copy, the memo is keyed by the bound tenant, any command call (local or remote) clears it, and the HTTP boundary never inherits another request's memo.
In-process contract UoWs use a dedicated per-database pool (CONTRACT_DB_POOL_SIZE) so a nested contract call succeeds even with DB_POOL_SIZE=1, and contract connections carry lock_timeout (CONTRACT_DB_LOCK_TIMEOUT_MS) so a contract waiting on its caller's row lock fails fast instead of deadlocking.
The typed wire codec v2 is used only for fully typed annotations and only once the provider is described and its contract hash matches the caller's accepted hash; any value not exactly fitting falls back to codec 1 with unchanged coercion/errors, and on the local path only immutable exact-type values skip the round trip (mutables are copied); codec 1 round-trips every wire value, local equals remote and codec 1 equals codec 2 when fully typed (properties), and an int beyond 64 bits crosses RPC tagged (bigint) so local still equals remote.
Every shipped contract (tenancy.membership.v1, billing.entitlements.v1, billing.credits.v1, billing.facts.v1, *.claims.v1) gives identical results and errors locally and over RPC under assert_contract_conformance.
Read contracts keep no idempotency record on either path, and the RPC layer adds no dedupe record of its own (commands share the contract runtime's single record).
Contract DTOs are authored in the provider and copied for callers: sandloom generate writes a wire-shape copy of every DTO a provided contract signature names (nested ones included) into /contracts/_dto.py, each class marked @dto_copy with its origin, and the stub names the copy and passes its Protocol (ContractRef(name, Protocol)). A Contract(REF) caller - and Contract(REF, optional=True).client() - receives results as the stub annotates them (the copy's class), on the local path and over RPC (with or without the provider's package imported in the caller process), equal to what the provider returned; a frozen copy of immutable scalars is rebuilt without revalidation, anything else through one JSON round trip; a result already of the stub's class, or naming no model / Enum, is not converted. The provider receives its own class for a copy the caller passes, on both paths.
Each bundled module commits contracts.snapshot.json equal to the rendered describe documents of its registered contracts; a stale or missing snapshot fails the suite. For L1 modules, sandloom generate compares every generated file with the committed one before writing anything (SANDLOOM-T004 contracts, T005 events, T007 stubs, C012 DTO copies, T001 a route that lost its authentication); sandloom test, dev and --ci alike, only checks and fails on a stale file, listing it under "generated files are stale: run sandloom generate".
The compatibility comparison reports as breaking a removed method/parameter, a newly required parameter, a changed position/kind/idempotency key and a narrowed parameter or incompatible result type, and passes additive changes (new methods, optional parameters/fields, widened parameter types).
sandloom contracts check --against <snapshot> exits 0 on additive changes and 1 on breaking ones (default --as provider), and with --as consumer reports methods the released provider lacks.
sandloom/core/tests/test_contract_compat.py::test_cli_check_against_snapshot gap (S): No test drives sandloom contracts check --against http://<provider> (live describe document over the internal token) or the sandloom contracts snapshot --write/--check CLI exit codes (only the underlying functions are tested).
G-COMPAT-04
With CONTRACT_COMPAT_CHECK=ready (default) a caller compares every reachable remote-bound provider's live describe document with the provider module's snapshot (found in its package without importing it): an incompatible provider fails /ready with contract_incompatible, an additive one passes.
ContractRequirement(name, methods=(...)) narrows the compatibility check to the methods the consumer calls, so breaks in other methods do not fail /ready.
CONTRACT_COMPAT_CHECK=strict aborts startup on a reachable incompatible provider, ready only logs at startup (and fails /ready), and off restores reachability-only readiness.
A provider describe document without model field information yields a warning (contracts_unverified), not an incompatibility failure.
sandloom/core/tests/test_contract_compat.py::test_model_without_field_info_is_a_warning_not_an_error gap (S): The startup log for a remote binding with no snapshot at all ("unchecked", CONTRACT_SNAPSHOT_PATHS absent) is not asserted.
The request key K_req is HKDF-SHA256 over the X25519 agreement of caller and provider with a role-ordered, direction-labelled info naming both services and kids: both sides derive the same key, the reverse direction and swapped ids give different keys, and it is domain-separated from the response-binding key (committed test vectors pin the schedule).
Low-order / non-canonical X25519 points and an all-zero shared secret never yield a usable pair key: they are refused at startup for every configured pair and at verification for a published caller key.
The MAC input is a fixed-arity length-prefixed (injective) encoding of caller, caller kid, provider, provider kid, HTTP method, target, nonce, raw context bytes and body SHA-256; tampering with any one field makes the request fail verification.
The provider verifies the single canonical X-SANDLOOM-RPC-Auth header and full 32-byte tag over the raw bytes before decoding the context or body; malformed headers, non-canonical/truncated tags, a nonce that is not 128-bit lowercase hex, unknown kids and contexts with duplicate JSON keys are all 401 rpc_auth_invalid.
Requests are audience-bound by construction: a request MACed for provider P never verifies at another service Q (aud must equal RPC_SERVICE_ID, the key names P), and a compromised peer cannot forge another caller's request to a third service.
A request is fresh only when |now - iat| <= RPC_AUTH_MAX_SKEW_SECONDS (symmetric); otherwise 401 rpc_auth_expired, including out-of-range iat values (never a 500), and accepted requests skewed beyond half the window are counted per caller.
The per-process nonce replay guard, consulted only after the tag verified and the caller is authorized, refuses a repeated (caller, nonce) within the window with 401 rpc_auth_replayed, keeps entries until the request could no longer be fresh, never evicts a live nonce (fails closed 503 rpc_replay_unavailable when full, never rebuilt) and caps one caller's share of the table.
With RPC_REPLAY_GUARD=commands (default) command calls (all calls with all) are also claimed by an atomic SET NX on the security cache under a collision-free key with a constant 3*skew TTL, so a replay landing on another process/replica is refused; a cache outage fails closed in prod and open outside it.
The provider binds the tenant only from the MAC-covered X-SANDLOOM-RPC-Context; a bare X-Tenant-Id header on an RPC request is 400 rpc_unsigned_tenant, and the proxy propagates the caller's bound tenant inside the MACed context.
RPC_ALLOWED_CALLS is deny-by-default and consulted with the verified MAC caller id: a verified caller not allowed the contract/method gets 403 rpc_caller_forbidden at the provider; between real processes the ops token alone opens no contract and a caller is held to its own bindings.
A caller whose own RPC_ALLOWED_CALLS entry does not cover every contract in its SERVICE_BINDINGS refuses to start, and the shipped microservices profile's allowlist equals the one derived from its bindings.
With RPC_COMMANDS_REQUIRE_SIGNATURE on, a command call (and every not-yet-described method) must also carry the MAC caller's own Ed25519 signature covering the context (incl. audience and the attempt's nonce), target and body, so a leaked provider X25519 key forges reads at most, never effects: forged, foreign-signed or re-MACed commands are refused with no effect, and all requires signatures on reads too.
RPC_COMMANDS_REQUIRE_SIGNATURE=auto is on in prod and off in dev/test; off and stale (unpublished) RPC_DH_EXTRA_PRIVATE_KEYS kids are refused under multi_tenant_strict (stale kids warned otherwise), and a calling service without RPC_PRIVATE_KEY refuses to start while the requirement is on.
Every SERVICE_BINDINGS entry must pin its provider with service_id (startup refuses otherwise), and a caller never sends a call it cannot MAC: an unpinned binding or a provider without a published X25519 key fails 503 rpc_auth_unavailable with nothing sent.
A process with SERVICE_BINDINGS refuses to start without its own RPC_DH_PRIVATE_KEY, a provider without one refuses every RPC call (unkeyed), and a monolith with no bindings needs no RPC keys.
Boot refuses unusable RPC key configurations: own X25519 key missing or not matching its published half, an Ed25519 private key not matching its published key, non-X25519 / non-Ed25519 keys in the maps, duplicate published X25519 keys, an underivable pair, and an X25519 key equal to the Ed25519 signing seed.
Every provider-side authentication/authorization rejection (rpc_auth_, rpc_identity_, rpc_context_*, rpc_caller_forbidden, rpc_replay_unavailable) and every unkeyed error answer surfaces at the caller as 503 RemoteServiceUnavailable carrying the provider's code, never as the provider's 401/403.
A caller accepts a response only when it echoes the attempt's nonce and carries a valid v2 pair-keyed MAC from the pinned service_id; stale, swapped, wrong-request-id, unsolicited, unbound, unkeyed, forged or other-service responses are rejected (never returned as a result, even under concurrency with random faults).
The response MAC covers the request's own X-SANDLOOM-RPC-Auth (and command signature), and a request carrying any auth/nonce/context/signature header twice is refused and never bound, so a provider's answer to a replayed or re-labelled request never verifies for another request.
Responses are keyed with an HKDF pair key over each service's DEDICATED X25519 key (never its Ed25519 signing key, no custom curve code); the provider keys call, describe and error responses only for a caller whose request MAC verified, and answers others (e.g. the 413 body cap) with the nonce alone, unkeyed; unverified callers never churn the key cache.
A response-binding rejection (other than an unkeyed refusal) discards the whole client connection pool and is a retryable transport error; a peer that always answers another call yields 503, never a result.
The aiohttp and httpx transports are behaviourally identical: both raise httpx's exception classes, pass the same fault-injection, pair-MAC and contract-conformance suites, and send pair-MACed requests with the tenant context.
The proxy retries a transport failure exactly once under a fresh nonce and tag (never seen as a replay; a signed command is re-signed and executes once), a second failure or an unreachable peer is 503, and a timeout is 503 documenting that the call may have committed.
A failing aiohttp internals probe or loopback self-test (unsolicited bytes on an idle connection) warns once, increments sandloom_rpc_transport_fallback_total and rebuilds the shared client on httpx, which serves calls; the pyproject pin equals VERIFIED_AIOHTTP_RANGE.
A pooled connection returns to the pool only after a complete keep-alive response: a timeout, cancellation, parse error or unsolicited byte closes it, so a half-read or stray response never reaches the next call.
No downgrade: an Ed25519-only request is 401 rpc_auth_required, a request with only X-Internal-Token or no credential gets the hidden 404 (INTERNAL_ENDPOINTS_OPEN_IN_DEV never opens /_sandloom/rpc), a bad MAC never falls back to a valid signature riding along, and a signed command stripped of its MAC is refused.
The removed settings RPC_AUTH_MODE and RPC_RESPONSE_BINDING (like every config_tiers.REMOVED entry) fail startup unless set to their inert value (service_mac / strict), so no token or unkeyed mode can be re-enabled.
Key rotation is per service and fails no call at any step: a provider accepts its current kid and RPC_DH_EXTRA_PRIVATE_KEYS kids (an unpublished extra kid is accepted but flagged), a dropped kid is refused, caller rotation needs no provider change, and the addressed kid is RPC_SERVICE_DH_CURRENT_KIDS or deterministic regardless of JSON order (an unpublished current kid is refused).
RPC rejections are logged with ids and kids only (never a key, tag or derived secret), rate-limited per reason and caller, and use a fixed, unique, declared reason vocabulary.
A business module's production code never imports another business module (static, relative, dynamic importlib/sys.modules/getattr spellings, in the framework tree and in an app's modules package); a violation fails sandloom check and the architecture suite.
A business module may import from the kernel ONLY the public_api whitelist (sandloom.core.di, facade, module_api, descriptors, orm, projections) plus sandloom.shared. and its own package; any other sandloom.core. import (settings, drivers, _internal, …) is a framework-imports violation.
The underscore submodules of the public packages (sandloom.core.module_api._, sandloom.core.facade._) are forbidden to business modules via architecture_rules.yml forbidden_patterns; modules import the package itself.
tests/architecture/test_archcheck_library.py::test_framework_manifest_passes_every_rule rule framework-imports gap (S): no test proves a module importing sandloom.core.module_api._service_registry or sandloom.core.facade._state is flagged (only that the tree is clean)
G-MODULE-04
Module code cannot reach kernel handles: .event_bus, .infra, .service_registry, request.app.state.core (incl. reflective getattr spellings) are rejected; only the public verbs (publish_event, register_event_consumer, cache(), capability(), get_service()) are allowed.
Business modules never import a backend SDK (boto3, motor, pymongo, neo4j, temporalio, kafka), the legacy globals (get_db/get_event_bus from sandloom.core, get_cache from sandloom.core.di) or the infra-internal modules (sandloom.core._internal, eventing.event_bus, storage, drivers, sql_alchemy.models).
A best-effort downgrade in module code (publish_event/record_audit with required=False, incl. aliased calls and **kwargs splats) needs a '# sandloom: best-effort ' marker on the call, else sandloom check fails; guaranteed calls are never flagged.
HTTP endpoints (router decorators) of a business module live only in its own api/ layer, judged relative to the module root (an api/ directory above the module does not exempt it).
API endpoints of a business module do not leak ORM models (no ORM types in endpoint signatures/returns; they return pydantic DTOs), and the legacy ORM_LEAKAGE_BASELINE is empty and may only shrink.
A business module's schemas/ (package or single schemas.py) never import SQLAlchemy or the module's models (absolute, relative or any models spelling).
Module tests import the framework only through a whitelist (public_api + shared_api + test_api sandloom.core.testing/sandloom.core.contract_testing + own package); any kernel internal or other business module import fails, and every test allowlist entry must still match a real import (stale entries fail).
register = define_module(name, ...) mounts .api's router, registers .events.EVENT_SCHEMAS and returns the descriptor; re-registering an identical schema is a no-op while a breaking schema change fails the load; inference refuses main; a business module named like a conventional submodule (tasks) owns its declarations, and Saga[T](...) records its constructing module.
Every bundled module (and every example app's module) boots ALONE through create_app + lifespan; only the documented ContractBindingError for required consumed contracts may refuse, and any startup/registration coupling or error logged at startup fails the boot.
Module tables never go in the core migration chain (infra-only: outbox/audit/consumer_inbox/contract_command_log); each module's tables live in exactly one module chain and the chains are disjoint.
An application derives its module list: sandloom.sdk.app.app_modules(".modules") finds, statically, every subpackage whose init defines register; sandloom new passes it to create_app as the default (SANDLOOM_MODULES selects a subset).
sandloom.core is layered (tooling > composition > contracts&RPC > auth&HTTP > drivers > messaging > persistence > bus > infra > foundation > config); every runtime import (module-level and function-local, only TYPE_CHECKING excluded) goes to the same or a lower layer, and the contract is exhaustive so a new sandloom.core child fails until placed.
The kernel has no import cycle at any depth (packages and modules, siblings of one layer included, sub-packages like unit_of_work. / facade.), counting function-local imports.
No import contract has ignore_imports; should one ever appear it must carry a written '; reason:' line (refused otherwise) and import-linter fails an unmatched entry.
Every kernel function-local import (# noqa: PLC0415) states its category '- optional: ' or '- cold: '; a cycle break is not a category and no kernel file is exempt.
Lint-suppression counts (noqa per rule) only go down, config-level ignores of ratcheted rules are pinned, and blind-except / bandit suppressions carry a reason.
The paradigm-agnostic kernel imports no SQLAlchemy at runtime (TYPE_CHECKING exempt); only the allowlisted relational surface (orm, tenant, di, sql_alchemy/, projections/upsert, migrations/, drivers) may, and eventing never imports the relational implementation.
Lower kernel modules never reach UP to the aggregators: no un-baselined runtime import of sandloom.core.facade or sandloom.core.di from inside the kernel, and the facade does not import the concrete RPC mechanism (ratchet baselines may only shrink).
The audited cross-tenant system channel (sandloom.core.drivers.system_db) is unreachable from business modules: it is a forbidden infra module, absent from the public core API, and the database provider exposes no system engine.
The enforced public surface (the public_api modules of architecture_rules.yml, including the sandloom.sdk modules of Public API 1.0, + sandloom.shared.* and the test surfaces) is snapshotted in public_api_baseline.json and a backward-incompatible change fails the build.
The framework carries no deprecated API: its own suite treats DeprecationWarning from sandloom as an error (pyproject filterwarnings 'error::DeprecationWarning:sandloom'), and generated apps set error::DeprecationWarning.
The kernel names no business module: sandloom.core settings carry no default module list, the standard root sandloom.main composes whatever module packages are installed (their sandloom.modules entry points), and the kernel factory alone builds a working app.
A backend constructor (create_async_engine, redis.from_url, Celery(...), Storage(...)) appears only inside sandloom.core.drivers/**; concrete drivers are imported only by the composition layer (app_factory, _internal.db_provider), and the public DI surface / ops health imports no concrete driver.
There is no implicit default registry: cache() / notifier() / any capability accessor with no registry bound raises RuntimeError in every environment, with a hint naming create_app, load_composition_core and the sandloom.core.testing harness.
A driver returning a provider that does not implement its capability's contract class is refused with ProviderContractError (also for an explicit contract mismatch at resolve time).
require_capability(..., features={...}) raises FeatureUnsupportedError when the configured driver does not advertise a requested feature, and an unknown feature name is refused.
Resolving an experimental capability (document_store, graph_store, workflow, search, lock, secrets) without a registered driver raises ExperimentalCapabilityError (also via the facade), with the precise error kept for an unknown instance.
The loader preflights a module's CapabilityRequirement list at load without I/O: a required requirement that is unknown / unconfigured / missing a feature fails the module load, an optional one only warns.
sandloom/core/tests/test_module_capabilities.py::test_preflight_fails_for_unsupported_feature sandloom/core/tests/test_module_capabilities.py::test_preflight_unknown_capability_raises sandloom/core/tests/test_module_capabilities.py::test_optional_requirement_does_not_raise sandloom/core/tests/test_module_capabilities.py::test_preflight_passes_for_satisfiable_requirement gap (S): no test asserts the preflight performs no I/O (opens no provider/connection), nor that a failed required preflight aborts create_app for a required module
G-INFRA-07
Infra configuration is validated at registry build: an unknown driver, an unknown instance, a new INFRA instance without a driver, or options failing the capability's config schema are refused with a clear error.
The Redis cache driver's clients follow the running event loop (rebuilt on a new loop, default and named instances alike; an injected client is never replaced), so no harness resets driver singletons.
Every Settings field is classified in exactly one configuration tier (semantic or ops), and every semantic setting defines a value for every profile (dev / test / prod / prod+multi_tenant_strict).
The semantic tier never exceeds 16 settings, a setting that leaves it cannot silently return (baseline only shrinks), and a new semantic setting outside the baseline must reference an ADR.
A removed setting (config_tiers.REMOVED, incl. the former ALLOW booleans, RPC_AUTH_MODE, RPC_RESPONSE_BINDING and AUTH_REVOCATION_STRICT_PATH_PREFIXES with inert value []) set to its inert value only logs a warning; any other value fails Settings construction / startup.
In prod, JWT_SECRET (and JWT_PREVIOUS_SECRETS), INTERNAL_API_TOKEN and METRICS_TOKEN shorter than 32 chars or looking like placeholders are rejected at Settings validation, and METRICS_TOKEN must differ from INTERNAL_API_TOKEN; dev keeps short values working.
A semantic setting weaker than its profile value is reported as an override (WARNING at start in prod, INFO for a dev opt-in), listed in /diagnostics/core configuration block and by sandloom doctor; a stronger choice is not reported.
sandloom/core/tests/test_config_tiers.py::test_prod_override_is_reported_loud_and_explicit sandloom/core/tests/test_config_tiers.py::test_stronger_choice_is_not_an_override sandloom/core/tests/test_config_tiers.py::test_dev_opt_in_is_logged_at_info sandloom/core/tests/test_config_tiers.py::test_doctor_lists_overrides gap (S): sandloom_config_override_active{setting,kind} metric export (app_factory) is not asserted by any test.
G-CONFIG-06
Framework runtime code adds no raw environment read (os.getenv / os.environ) beyond the pinned per-file baseline (the composition root, gunicorn config and bootstrap switches); every new knob is a validated Settings field.
The boot-time configuration checks (enforce_configuration) refuse an invalid configuration identically in every entry point: the app, the Celery worker, sandloom migrate and the sandloom CLI.
The generated configuration-tiers reference renders every semantic setting (with its reason) and every ops setting, with the per-profile value table.
sandloom/core/tests/test_config_tiers.py::test_generated_reference_lists_every_tier gap (S): AGENTS.md and accepted_risks error messages point at docs/reference/configuration-tiers.md, but that file does not exist in the repo (only tiers.render_markdown() in memory); nothing checks the committed page exists / is current.
Each accepted risk in effect in prod is logged as a WARNING 'ACCEPTED RISK' at every start, reported as an override of kind accepted_risk, listed in /diagnostics/core configuration.accepted_risks and flagged WARN by sandloom doctor; a prod-only risk accepted outside prod is listed with in_effect=false and is not an override.
sandloom/core/tests/test_config_tiers.py::test_accepted_risk_is_logged_loud_counted_and_listed gap (S): Export of sandloom_accepted_risk_active{risk}=1 (app_factory) is never asserted; a test reading the metric after create_app in prod with a risk accepted is missing.
G-RISK-03
Every registered risk documents what it weakens and which check consults it, and the registry holds exactly the nine ids fake_psp_in_prod, log_notifier_in_prod, local_storage_in_prod, volatile_event_bus, unversioned_ddl_repair, weak_password_hash, shared_hs_secret_split, relay_disabled, command_dedupe_below_retry_horizon.
In prod the volatile event-bus drivers (memory, redis Pub/Sub) are refused unless volatile_event_bus is accepted; required/durable consumers on a Pub/Sub bus are refused even with the risk, and outside prod need the risk.
OUTBOX_RELAY_ENABLED=off in a production app process is refused at startup (app_factory.outbox_relay_problem) unless relay_disabled is accepted, and is then reported as an override.
In prod the log notifier advertises no delivery channel features, so a CapabilityRequirement('notification', features={'email'}) fails, unless log_notifier_in_prod is accepted.
A split prod process using a shared HS JWT secret is refused unless shared_hs_secret_split is accepted, and the multi_tenant_strict profile refuses that risk outright.
sandloom migrate ensure-outbox --force --apply in prod is refused (exit 1) unless unversioned_ddl_repair is accepted.
gap (S): No test exercises the unversioned_ddl_repair gate in sandloom/core/cli/migrate.py (only ensure-outbox dry-run / dev apply are tested).
G-RISK-12
Production startup refuses a command dedupe TTL below the retry horizon (GuaranteeWindowError naming [command_dedupe_below_retry_horizon]) unless that risk is accepted; dev only warns.
Each module owns its own Alembic chain with version table alembic_version_; chains are disjoint, together cover every ORM table, and reproduce the ORM schema exactly.
A module chain is applied to exactly one database: migrations_database if set, else the single required database requirement, else default; optional requirements never host it, and several required instances without default fail unless migrations_database is set.
Migration runs hold a per-database advisory lock: a concurrent run waits at most --lock-timeout seconds (must be positive) and logs the holder's pid / application_name.
The core Alembic chain creates only shared infrastructure tables (outbox_messages, audit_log, consumer_inbox, contract_command_log, http_idempotency_log); module tables never go in it.
tests/integration/test_migrations_per_chain.py::test_core_chain_owns_only_infra_tables gap (S): Doc drift: AGENTS.md lists four core tables (outbox/audit/consumer_inbox/contract_command_log) but the chain (and the test) also own http_idempotency_log.
G-MIGRATE-06
sandloom migrate ensure-outbox is a dry run unless --apply, and ensure-outbox --apply is refused in prod without --force (prod uses upgrade-core).
sandloom/core/tests/test_migrations_multidb.py::test_ensure_outbox_dry_run_is_noop gap (S): The prod refusal of ensure-outbox --apply without --force (cli/migrate.py) is untested.
G-MIGRATE-07
sandloom migrate extract-module is a dry run by default (writes nothing); --apply requires --i-have-stopped-writes, the fence blocks a live writer, and a same-database, non-empty target, a source behind head, or an RLS-enabled target role are refused before copying.
extract-module is crash-resumable and verifiable (row counts + checksums vs manifest), --unfence rolls back only before completion (after it needs --force), --drop-source is a separate explicit cleanup, and the consumer group-owner fingerprint is transferred to the target database.
sandloom migrate resolves the module set through the composition root (SANDLOOM_COMPOSITION_ROOT) and upgrade-all exits non-zero on failure, including a database stamped at a non-head revision.
Module chains are applied in a deterministic order honouring declared dependencies; a dependency cycle or a missing dependency of a required module is fatal.
sandloom migrate drift (and the sandloom test step "migration drift", SANDLOOM-T006) applies the core chain and every module chain, in the upgrade-all dependency order, to a scratch database on the default database's server (created and dropped by the check) and compares each chain with its models (each env.py compares only the module's tables); a difference fails naming the module, the operations found and the fix (sandloom migrate new ""). The configured databases are never written.
Celery beat schedules only while it holds a fenced cache lease: a second beat stands by and takes over on release/death, a holder that lost its token stops at once, a cache outage keeps scheduling only until a local deadline, and beat refuses to start without a CAS-capable cache.
/health and /ready answer 503 when degraded; public callers see check statuses only (details and /ready topology need X-Internal-Token) and results are cached for HEALTH_CACHE_TTL_SECONDS.
Peers never feed /health (liveness); a down peer fails only /ready and only when its SERVICE_BINDINGS entry is required: true, with concurrent, capped, authenticated probes.
/metrics, /diagnostics/, /contracts/ and health details are closed without INTERNAL_API_TOKEN (open in dev only with INTERNAL_ENDPOINTS_OPEN_IN_DEV, never in prod); METRICS_TOKEN unlocks /metrics only.
Workers are health-checked by python -m sandloom.core.worker healthcheck, which fails when the heartbeat written on the worker timer is stale; every deploy profile's worker uses it and is sized from the CPU quota.
Under gunicorn exactly one elected worker runs the outbox relay and the others are delegated; the leader is re-elected after a crash, TTOU retirement or SIGHUP reload, and delegated is refused outside a gunicorn worker.
A test run refuses (exit 4, before any test) a test database or Redis index stamped by another owner (SANDLOOM_TEST_OWNER / checkout root) unless SANDLOOM_TEST_TAKE_OWNERSHIP=1; flush_owned_redis never flushes another run's Redis.
Test processes get small pools and the session fails fast before any test when the Postgres server lacks connection headroom; -n auto is capped to the server.
create_app inside a test does not leak: the harness restores the active core / infra / bus / module lists after every test; every test module starts from the harness app's runtime (built before the first module, whatever ran before); what a test registers on the bound core (event schemas, services, startup tasks, declared operations, ...) is rewound after it, SQLAlchemy's class-level event flags are put back and the RPC client's description caches emptied. The test steps of sandloom test and CI run in a seeded random order (printed, replayable), and CI runs the whole framework suite serially in one shuffled process.
sandloom security revoke-sessions / force-password-reset are dry runs (count targets, call no command) unless --apply --reason, require exactly one target, and in prod also --confirm-production.
sandloom/core/tests/test_security_cli.py::test_dry_run_counts_targets_and_calls_no_command sandloom/core/tests/test_security_cli.py::test_cli_requires_exactly_one_target_and_a_reason_to_apply gap (S): The prod --confirm-production requirement of sandloom security is untested (only sandloom backup's guard rails test it).
G-OPS-13
Logs (structured extras and free-text messages), exception descriptions and Sentry events are scrubbed of secrets/PII before emission.
In both compose profiles every security/auth-relevant setting (shared env anchor: JWT_* except issuer-only keys, token lifetimes, INTERNAL_API_TOKEN, notifier/SMTP, BILLING_DENY_ON_PAST_DUE) and CACHE_URL are identical in every process.
The microservices gateway never routes /_sandloom, /diagnostics or /metrics, re-resolves services through Docker DNS, and caps bodies at the app upload limit.
Each app/worker process waits for its one-shot migrate service (sandloom migrate upgrade-all --apply, depends_on service_completed_successfully) against its own database before starting.
sandloom backup verify fails when the restore fails, a chain is behind its head, or orphan rows exist, always dropping its scratch database; destructive operator paths in prod need --confirm-production.
After a security-cache restore from a stale snapshot, sandloom backup reconcile-security re-derives block and revocation marks from durable records so no blocked user is re-enabled and no newer mark in the cache is lowered.
Every module listed explicitly in SANDLOOM_MODULES is required (load failure aborts startup) unless MODULE_REQUIRED names a subset, and every deploy process lists its modules as required.
No secret file the generators write enters an sandloom new application's image. Its .dockerignore excludes env files (all but .env.example) and the key files of sandloom keys generate --out-dir (jwt_private_key, rpc_private_key, rpc_dh_private_key, *.pem) at any depth, so the split's deploy/.env.microservices (every service's private keys) stays out of the build context; its Dockerfile copies only pyproject.toml, uv.lock and the package into the build stage and nothing of the context into the runtime stage; sandloom add deploy patches an existing app's .dockerignore that lacks the exclusions; the app-split job exports the app's own build context with Docker and finds no secret file, and the smoke builds the service image from that context and finds none in it.
register_saga refuses a malformed definition deterministically at registration (duplicate steps, missing compensation, steps on the wrong side of the pivot, more than one pivot, a call that is not a command) with a fix hint.
Saga instance tables are reached across tenants only through the audited system channel under tenant RLS. On every database, with or without tenant RLS, the runtime itself scopes saga state to the bound tenant (round 3) - saga_status and start_saga's create-or-get read only the bound tenant's instance (no tenant bound - only a tenant-less saga), so another tenant asking for a business id gets nothing (no instance id, status or step) and its start conflicts, and a nudge moves an instance only of the tenant it names; only the system channel (sweep, operator commands) and the test harness read across tenants (saga_store.ANY_TENANT, a required argument of every instance read). The bound tenant is read once the unit is open (round 4) - saga_status outside a unit opens its own, which binds SINGLE_TENANT_ID under TENANCY_MODE=single, so a canonical-tenant saga is found, not reported absent.
Every command step carries the derived operation key saga::::do|undo; non-deterministic arguments hit the command log's conflict and park the instance.
start_saga runs in the caller's Unit of Work (instance row and first nudge commit with the business change), is create-or-get on saga_id, conflicts on different input, and is refused outside a UoW.
Duplicate, stale or concurrent nudges never run a step twice: the row lock and version check make them no-ops, and a locked instance is skipped, not waited for.
Step outcomes are classified by an explicit closed table (408/423/425/429/5xx transient; domain 4xx refused; key conflict and misconfiguration park); an unknown status is transient.
Recovery: a refusal before the pivot compensates applied steps in reverse; after the pivot, or of a compensation, it parks with an audit row; transient outcomes retry with full-jitter backoff until the deadline, then compensate or park.
A step with an ambiguous outcome is resolved (re-issued with the same key) before it is compensated; a timeout racing a completing step is serialized by the row lock; also after a crash that rolled back the attempt count: a command step's first forward call writes a durable started marker in its own transaction, and a deadline resolves a step that may have applied (attempt > 0 or the marker) before compensating.
No blind re-call after the command-dedupe TTL: an operator retry of a step with an unknown outcome is refused (resolve first), and the runtime parks such a step with outcome_unknown_after_ttl; a call whose transition rolled back after the provider committed (only its durable started marker survives) is recovered as a transient attempt from the marker's own time, so recovery after the TTL parks it instead of re-issuing the command.
Importing a public sandloom.sdk module, sandloom.sdk.testing or a bundled module (identity, tenancy, billing) is side-effect free: with python -I, an empty environment and no .env it reads no environment variable, constructs no Settings, opens no socket or non-package file, installs no logging, signal or atexit handler, starts no thread and builds no engine or client; help(sandloom.sdk.events) works. Settings are lazy and every entry point forces them (enforce_configuration / load_settings), so an invalid environment still fails at startup.
sandloom.sdk imports only the public kernel surface (no kernel internal, no private submodule of a public kernel package); the kernel (sandloom.core, sandloom.shared) never imports sandloom.sdk; L1 (sandloom.sdk._decl) imports no kernel module; and sandloom.sdk is layered public modules > _decl > _prim.
L1 registration equals L2: for a fixture module per L1 feature (router, producer events, views and consumers, a contract provider, a saga with a typed step, a projection with full-state, tombstone and delta groups) and for every example module, the descriptor and the recorded registration calls built by sandloom.sdk define_module equal those of a hand-written L2 register; the module_router routes carry exactly the per-route dependencies of the L2 form.
Generated code (sandloom new, every sandloom add generator) and every example file that imports sandloom.sdk use only the 29 beginner names; an advanced name needs # sandloom: advanced <reason> on its import (SANDLOOM-T009).
Every name sandloom.sdk._decl (L1) imports from sandloom.sdk._prim is exported by a public sandloom.sdk module, and every _prim name is public: each L1 dependency is an advertised L2 primitive; the H1 names (public, at_commit, db, open_uow, sensitive_effect, MoneyLedger, Account, authorizes, ...) are the kernel objects themselves, no wrapper.
An Event class is a producer when its channel prefix is the defining module (or stated), else a view; channels are versioned, fields are from the closed set of wire shapes (round 10: an event payload is plain data - scalars (str, int, float, bool, None, UUID, datetime, date, time, timedelta, Decimal, Enum, Literal, Any as an opaque JSON value), X | None and (round 11) unions of Literal / Enum members none of which reads another member's published value, list / tuple / set and frozenset of scalars / dict[str, T], pydantic BaseModel DTOs, and unions of DTOs only when discriminated by a Literal tag field; every other shape at any depth is refused at class definition with SANDLOOM-B015 naming the field path and the fix: (round 11) a union of scalars whose members read the same value (int | str, UUID | str, float | int, datetime | str, Literal[1] | Literal[True]) in any union_mode, strict or not, an empty wire name (alias, serialization_alias or an alias_generator returning ""), two fields with one wire name or a field named like another field's wire name in a model that reads by name, a model_config option outside the whitelist (ser_json_timedelta / temporal / inf_nan / bytes, 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, any unlisted key), a float that may be NaN or infinite (the Event base sets allow_inf_nan=False; a DTO states it), SerializeAsAny and any other node serializer, coerce_numbers_to_str on a field - so every class that defines round-trips: a property test over random classes from the whole set and from the refused shapes and options holds every one that defines to to_wire, standard JSON (no NaN), from_wire returning the same value and type at every depth, agreeing with the strict class's model_validate_json (fields set included); a rebuilt class (model_rebuild) is checked again and read by a reader of its new schema; from_wire refuses a DTO instance (it reads JSON data) -, an undiscriminated union holding a DTO or a container in any union_mode, a callable discriminator, a DTO overriding init or new, a mode=before / wrap validator over a DTO or a plain validator, a custom serializer or computed field, a wire name that does not round-trip - serialization_alias or validation_alias alone, AliasChoices, AliasPath, an alias the model does not read -, an excluded field without a default, dataclasses, TypedDicts, NamedTuples, RootModels, unparametrised generics, non-JSON scalars, non-str dict keys, sets of non-scalars, and a forward reference left unresolved), one producer per channel and module, a stated producer outside its module is refused (SANDLOOM-B015); a view never publishes (SANDLOOM-C025 at run time). A producer registers its wire schema (E.wire_schema(): the serialization schema, so a defaulted field is required, with no additionalProperties: false, so a reader accepts a newer producer's added optional field; construction stays strict), and it is snapshotted (module.snapshot.json) and exported to the shared DTO package; a change on the same .vN other than an added optional field fails before anything is written (SANDLOOM-T005), in dev and CI, at every depth: $ref / $defs are resolved, so a referenced enum's values or a nested DTO's field types, removals and newly required fields fail like top-level ones, while an added defaulted (optional) field passes at any depth - a default of any kind, literal or factory (round 7: Field(default_factory=list / dict / set / a model) carries no JSON default, so the wire schema marks every defaulted property "x-sandloom-default": true and the rule and the live schema registry read that mark; a new reader fills the field in an old payload, an old reader ignores it in a new one, while the same field added without a default still fails as newly required); a field that loses its default fails - literal or factory, top level or nested - although the serialization schema's required list is unchanged, since a producer from before the field omits it (round 8: the rule and the live schema registry, which also compares every shared $defs entry, read the lost mark), while adding a default to a required field passes; the mark is framework-owned: a json_schema_extra that sets "x-sandloom-default" (a field, a nested model, a callable) is refused at class definition (SANDLOOM-B015), so it always means "has a default"; readers ignore unknown keys at every depth (round 9: from_wire validates once with a lenient twin of the class's own validation schema, in which every extra="forbid" DTO - nested, in discriminated unions, lists, dicts, tuples and recursive definitions - ignores extra keys while extra="allow" ones keep theirs; aliases, discriminators and validators are the class's own and run once, the result and every nested value are instances of their real classes, equal to constructed ones, and a real error is raised as is; a FailFast list of any length reads; round 10: the reader is built when the class is defined and compiling it modifies no class - its model nodes name factory classes whose new allocates the real class, which pydantic-core fills and post-inits - so concurrent first decodes never cache a strict reader, nothing in the reader is strict (a strict=True DTO reads its published strings), and a self-check verifies that pydantic-core contract on a canary before the first reader is stored, failing loudly with the pydantic versions if an upgrade breaks it), so an optional field added inside a strict nested DTO is safe for its old reader, the live schema registry compares enum members as JSON values (a list or object member, mixed types), and the wire schema carries no additionalProperties: false at any depth; a recursive DTO (self- or mutually recursive) is compared once per ($ref old, $ref new) pair on a descent, so it never recurses forever and a change inside the cycle still fails; a validation constraint that narrows the accepted values fails (a bound raised / lowered or added, inclusive to exclusive, minLength / maxLength / minItems / maxItems / minProperties / maxProperties tightened or added, multipleOf added or no longer dividing the old one, pattern added or changed, uniqueItems or additionalProperties: false added; format and const changes fail as type changes), and so does one that loosens them (round 6: producers and consumers of one .vN deploy independently, so the rule holds both directions - a new reader accepts every payload an old producer may send AND an old reader accepts every payload a new producer may send: a loosened or removed bound, size, multipleOf, pattern, uniqueItems, additionalProperties: false, dependentRequired, contains or then / else / dependentSchemas constraint fails, and a model's field is never removed, open to extra keys or not - removing a required field from an extra="allow" model fails since the new producer omits it and the old reader requires it, and an optional field added to a model whose old schema forbids extra keys fails); every subschema applicator is traversed with the same rule (round 3) - a dictionary's key constraints (propertyNames) and its regex-keyed values (patternProperties: a value change under a pattern fails, an added, changed or removed key pattern fails), tuple positions (prefixItems), contains (added or narrowed) / minContains / maxContains, then / else, dependentSchemas and dependentRequired (added or narrowed), unevaluatedItems / unevaluatedProperties (narrowed, or false added) and items: false added, while any change of a not or if subschema fails; required is compared in every subschema with or without properties (round 4) - a dictionary's or a dependentSchemas / then / allOf subschema's added required name fails - an added property is compared with the schema that governed that key (a dictionary's additionalProperties or matching patternProperties, {} below an overlaying applicator), only a closed DTO (no additionalProperties, or false) may gain an optional field no old producer sent - a model open to extra keys (extra="allow": additionalProperties true or a schema) is an open object, so a field it newly declares is compared with what its old additionalProperties allowed (true: only a field accepting anything passes; round 5) -, a keyword beside a $ref is compared on its own, and a removed key pattern's keys are compared with the new additionalProperties; a differential test holds the rule to the framework validator - for every pair of a corpus of mutated pydantic wire schemas (every narrowing and loosening keyword at every position and under every applicator, plus hypothesis-composed chains; extra="allow" models, field removals, and additionalProperties true / a schema added to a DTO, included) the rule accepts, no payload sampled from the old schema fails the new one and no payload sampled from the new schema fails the old one (round 6; the sampler sends undeclared keys for a dictionary or an open model; round 8: the sampler omits defaulted fields as a producer from before them would, readers fill them, and the corpus removes defaults); the differential also runs on pairs of real Event classes with the REAL readers (from_wire of the old and new class: additions in strict, lax and open nested DTOs, lists, unions and dicts - round 10: every allowed shape - each scalar and container of scalars beside an added field, a DTO alone, optional, in lists, variadic and fixed tuples, dicts, deep containers and FailFast lists, discriminated unions alone, optional, in lists and dicts, strict, lax, open, aliased, recursive, validated and two-level nested DTOs -, and Decimal / UUID / Enum retypings, which fail (a Decimal is format: decimal in the wire schema, so its retyping to str is a type change the old Decimal reader would reject), default removals, added required and removed fields), and every such class's wire schema accepts exactly what its real reader accepts, an undeclared key at every depth included; the static view check (SANDLOOM-C025) sees fields inherited from a same-file abstract base. A field named like the Event API (channel, meta, publish, wire_schema) is refused at class definition, abstract bases included, with SANDLOOM-B015 naming the name and the fix (another name, or Field(alias=...)).
@on(E) registers through register_event_consumer with every option (name, durable, required, dead_letter, start_from, group, database) and binds the tenant from a tenant_id: UUID field (or a named UUID field, or none); a handler whose parameter is not E, a non-async handler, a duplicate name or a bad tenant field is refused (SANDLOOM-B016); deliveries decode the payload into E (unknown fields ignored) with EventMeta attached and never part of the payload.
define_module derives consumes from module-level Contract(...) references: name and required exact; methods only from direct <name>.<method>( calls on the name or a direct alias, any other use giving () (every method); merged with explicit consumes never weaker (required ORed, methods unioned or ()); a saga's contract requirements merge into consumes by name the same way.
Contract(REF, optional=True).client() is None only when the provider is neither local nor bound in the deployment (ServiceNotFound / ServiceUnavailableInDeployment); a deployed provider is returned even when it will fail (503 on the call).
sandloom check SANDLOOM-C021 refuses a function-local import in a business module (typing-only imports and a marked optional dependency excepted). SANDLOOM-C021 is un-planned and active in sandloom check: the bundled modules (identity, tenancy, billing) and the examples carry no function-local import (qrcode keeps a deferred import marked # sandloom: optional-import), so the framework tree and the examples report none.
A typed event field with a wire name (Field(alias=...)) round-trips: publish() emits the alias, the registered and exported schema names it, and every reader (an @on consumer of the producer class or of a view, a projection, published(), deliver()) decodes it back from the wire key; @on and a projection bind the tenant from the field's wire name.
SANDLOOM-C012. A contract DTO (a model / Enum a @contract signature names, nested ones included, except classes of sandloom.shared and generated copies) is held to the closed set of event wire shapes (G-DECL-01) within the contract wire types, plus what its generated copy can carry: no parametrised generic, a default the copy can write (JSON scalars, Enum members, UUID / Decimal / date / datetime / time / timedelta, containers of them) and default_factory only list / dict / set / frozenset / tuple, Field constraints only (no custom annotated constraint, no StringConstraints transformation, no callable discriminator, no exclude_if), no Flag enum; anything else is refused when @contract decorates the provider (ContractDtoError) naming the method, the field path and the fix. The copy carries fields, types and constraints, defaults, aliases (an alias_generator resolved per field), Literal / Enum values, nested and discriminated models and the allowed model_config options - never methods or validators, which stay the provider's; over the whole set (a property test over random payloads) the copy accepts and refuses exactly what the provider's class does, in python and JSON mode, and reads accepted payloads to the same data. Contract wire dumps use each field's wire name (by_alias), so an aliased DTO round-trips.
A model opts into fail-closed tenant scoping by inheriting sandloom.core.orm.TenantScoped, which declares the tenant_id column and registers it with the ORM guard.
Endpoints bind the request tenant with the TenantScope() dependency (configurable header and claim keys, required or optional), and workers/scripts bind one with bind_tenant(id).
sandloom/core/tests/test_tenant_isolation.py::test_header_can_select_tenant_from_token_allowlist sandloom/core/tests/test_tenant_isolation.py::test_automatic_filtering_to_bound_tenant gap (S): TenantScope(required=False) yielding None and leaving the context unbound, and custom header/claim_keys, are untested.
C-TENANT-03
Deliberate cross-tenant admin/system access on the ORM path is available with an explicit with unscoped(): block (including bulk-by-PK updates), and logs a one-time warning when used with no tenant on an RLS database.
Single-tenant deployments run with TENANCY_MODE=single plus SINGLE_TENANT_ID, binding that canonical tenant everywhere as a strict special case of the same isolation model.
PostgreSQL Row-Level Security is opt-in per database instance with INFRA database option tenant_rls=true (installing the per-transaction sandloom.tenant_id hook and advertising the tenant_rls feature) plus enable_tenant_rls/disable_tenant_rls in module migrations.
Kernel/ops code can run audited cross-tenant work through sandloom.core.drivers.system_db.system_session(actor=, reason=, database=, tenant_id=) over a separate sandloom_system login (DATABASE_SYSTEM_URL / system_url).
PRODUCTION_PROFILE=multi_tenant_strict is available to turn the multi-tenant trust contract (ORM guard + RLS on every database, fail-closed revocation, separate security Redis, split checks) into enforced boot and startup checks.
Security-critical cache state can be routed to its own Redis with SECURITY_CACHE_INSTANCE, and idempotency records optionally to another with IDEMPOTENCY_CACHE_INSTANCE (default empty = follows the security cache).
A deliberately global table with a tenant_id column can be exempted from the tenant-scoped-models rule via tenant_scope_exempt in architecture_rules.yml with a written reason.
The TenantScoped mixin declares no plain tenant_id index on a table whose primary key, unique constraint or unique index leads with tenant_id (that key serves the lookups), and keeps it otherwise.
publish_event_versions dual-publishes one event on several channel versions (.v1 + .v2) through the outbox, re-exported from sandloom.core.di, so a channel can be rolled without starving live readers.
sandloom/core/tests/test_streams_split_identity.py::test_publish_event_versions_is_reexported_for_modules sandloom/core/tests/test_streams_split_identity.py::test_dual_publish_does_not_warn_about_starvation sandloom/core/tests/test_uow_outbox.py::test_publish_event_versions_outside_uow_raises_unless_best_effort gap (S): No test proves that publish_event_versions inside a UoW writes all versions as outbox rows atomically (rolled back together).
C-EVENT-02
Modules react to other modules' events through core.register_event_consumer(channel, handler, name=...) with options group=, start_from=, bind_tenant_from=, dead_letter=, durable=, required=, all forwarded by the di helper through one signature.
sandloom streams inventory reports live consumers per channel version (flagging starved versions) and per-group lag/pending, used to decide when a .v1 reader is gone.
tenancy.member_added / member_removed / member_role_changed .v2 (tenant_id, required per-membership version) are dual-published with v1, and billing.subscription.*.v1 carry the catalog plan in data.plan.
The framework Celery worker (celery -A sandloom.core.worker) boots the composition root, takes its app from the taskqueue capability and imports .tasks plus setup_periodic_tasks of every loaded module.
The sandloom.core.testing seams run_on_commit / bind_test_uow / recording_uow let tests own the transaction while still running before_commit checks then on_commit callbacks, and capture required audits.
SSO login/link over OAuth2 with one-time, TTL-bound, browser-bound CSRF state tokens (reuse and expiry refused; link requires an authenticated link state).
Asymmetric JWT split: sandloom keys generate produces a usable key pair (stdout or SECRETS_DIR files), identity declares itself the token issuer (issues_tokens=True), and split services verify with public keys only.
Emergency stop: block_user / unblock_user from sandloom.core.di plus identity's platform-admin block / unblock / status / revoke-sessions endpoints (Idempotency-Key, audited, operator cannot block themself); the sandloom security incident CLI is area F.
Refresh-token rotation with reuse detection: each refresh rotates within the family, reuse of a rotated token revokes the whole family, a benign replay within the grace window is 409, concurrent refreshes yield one winner.
Composable authz algebra (require / enforce / And / Or / Not over Perm, Role, Owner, Subscribed, SubscriptionActive, Credits, Entitled; the billing atoms take a required of=) as the single enforcement path.
subject_commit_check(request), at_commit() (an explicit commit obligation on a non-write route; replaces revocation_at_commit()) and before_commit(callback) as reusable kernel hooks; the re-check runs before the COMMIT of every committing unit of the request and is automatic on writes and writing GETs.
current_service is a service authenticator: it accepts the internal token (404 otherwise, also for a wrong token), resolves the route's auth as "service" and returns the service principal.
no_idempotency(reason=) opts a write route out of the automatic idempotency guard; an explicit idempotency_guard(...) keeps all its parameters (scope, durable, store_response, fail_closed) and its misuse checks.
sensitive(risk) is the escape hatch for a hand-rolled authority check: it resolves {risk: ("sensitive()",)}; sensitive("security") derives a 300 s step-up; an unknown risk is refused.
sandloom check untagged-sensitive (SANDLOOM-C022, warning) reports a money command without risk="money", a credential / session change (revocation, block, unblock) without @sensitive_effect, and a write route that decides an authority by hand (an authority call or a 403 in its body, at_commit(), a membership change behind a member-level gate) without sensitive(risk); a route behind a framework admin gate (route, parameter or router dependencies) or public() is not reported. On the framework tree the mixed-authority routes and owning administration effects declare admin risk; the shared identity session-revocation mechanism has one local, reasoned waiver because its caller owns the authorization and credential-step-up policy.
Best-effort footgun detection (detection, not a guarantee): with DB_SESSION_OWNER_CHECKS on (the test environment's default; off in production, kept off the hot path by the performance gate) a session object used from another task is detected on its kernel-session methods and at the cursor; db() from a non-owner task is always refused (C-UNIT-01). Known undetectable forms: in-place mutation of a mutable column value (JSON) and attribute assignment on a loaded instance (no attribute listeners, kept off the hot path by the performance gate).
The same code deploys as a monolith or as any subset of identity/tenancy/billing purely via SANDLOOM_MODULES + SERVICE_BINDINGS: bound subsets answer the request script identically to the monolith, unbound ones identically or with an explicit 404/501/503 (never a 500 or a different 2xx), on both transports.
core.get_service resolves local-first: a co-deployed provider wins over a SERVICE_BINDINGS proxy, otherwise a generated RPC proxy is returned (reused until the binding changes), and with neither it raises ServiceNotFound so consumers degrade explicitly.
Every bundled module subset (and each module alone through create_app + lifespan) boots and becomes ready, refusing only with the documented ContractBindingError for a required consumed contract.
Remote peers never feed /health; a down peer fails /ready only for a binding marked "required": true, and required probes run concurrently, each capped at SERVICE_PROBE_TIMEOUT_SECONDS.
SERVICE_BINDINGS is strictly validated at startup: only base_url/url (absolute http(s)), timeout_seconds, critical, required and service_id are accepted; unknown keys (typos) and an internal_token are errors.
The shipped microservices profile binds exactly each service's consumed-but-not-local contracts (identity binds the remote claims contracts), derives RPC_ALLOWED_CALLS from those bindings, and every service keys its responses with callers pinning providers.
A token issuer deployed alone collects other modules' claims over RPC through their .claims.v1 binding; a critical remote contributor failure aborts minting with 503.
RPC_TRANSPORT selects the shared outbound client: aiohttp (default, production) or httpx (fallback, for egress proxies); both serve calls, including TLS to a self-signed peer trusted via SSL_CERT_FILE.
The aiohttp client keeps a bounded per-peer keep-alive pool (RPC_POOL_MAX_CONNECTIONS, waits bounded by the call timeout and observed) with idle eviction after RPC_POOL_IDLE_SECONDS.
sandloom keys generate --purpose rpc --service <id> emits a separate Ed25519 + X25519 pair (with RPC_SERVICE_KEYS / RPC_SERVICE_DH_KEYS entries) that validates as a working configuration, and --purpose rpc-dh emits the X25519 pair only (optionally into --out-dir).
sandloom.core.contract_testing.assert_contract_conformance(core, name, [ContractCase(...)]) lets an app prove a contract answers identically locally and through a generated proxy (in-process ASGI or a real aiohttp/httpx socket), also under the pair MAC.
A new backend is a driver, not a core edit: inside the kernel, any provider implementing a contract plugs in through the registry (a non-SQLAlchemy database driver included), and a CapabilitySpec adds a capability. A driver package can do so for the contracts sandloom.driverkit publishes and for capabilities of its own.
Driver packages are found by name in the sandloom.drivers entry-point group and imported only when INFRA selects them, so load order does not matter and an unselected package runs no code. A selected name with two providers, or one that cannot be used, stops startup (SANDLOOM-B021, B022).
With no extra config one default instance per capability is synthesized from the flat settings; the INFRA JSON env var adds named instances or overrides drivers/options.
Several databases run at once as independent engines/pools, each with its own UoW and its own transactional outbox; with several active sessions the target database must be named (database=...).
Endpoints resolve infrastructure by logical name through DI: Annotated[AsyncSession, Database("analytics")] for a named database and Capability(cap, Contract, name) for any capability.
sandloom/core/tests/test_uow_commits_before_response.py::test_dbsession_and_database_markers_use_function_scope sandloom/core/tests/test_async_dependencies.py::test_dependency_factories_are_async sandloom/core/tests/test_kernel_review_regressions.py::test_revocation_at_commit_covers_named_db_in_either_dependency_order gap (S): no test resolves a Capability(...) dependency in a request and asserts it yields the named provider (or the contract/feature error)
C-INFRA-06
document_store, graph_store, workflow, search, lock and secrets ship as CapabilitySpecs with contracts, marked stability=experimental, registered at bootstrap without a core driver; a plugin supplies the driver.
notifier().send(Notification(...)) works with the zero-dependency log driver by default (never logging the raw recipient; no delivery features advertised in prod unless opted in) and with NOTIFIER_BACKEND=smtp for email (STARTTLS verifies the certificate; login without TLS refused in prod).
The object store is key-addressed (put(key, data, content_type)/get/delete/url): callers pass a logical key + bytes, never a host path; traversal and symlink escapes out of the root are rejected.
The cache capability is operation-based (get/set/add/delete/incr, cas compare_and_set/compare_and_delete, fused incr_window/incr_many/set_nx_get/set_max) and never hands modules a raw client.
/diagnostics/infra reports configured capabilities, instances, stability, registered drivers and per-instance health (registry.describe()).
sandloom/core/tests/test_infra_registry.py::test_describe_snapshot sandloom/core/tests/test_capabilities.py::test_describe_includes_capabilities sandloom/core/tests/test_extra_capabilities.py::test_describe_renders_stability sandloom/core/tests/test_infra_registry.py::test_health_all_and_aclose_all gap (S): no test calls the /diagnostics/infra endpoint itself (only registry.describe(); the route appears only in the operation-class map)
C-INFRA-11
core.capability(cap, name) is the universal accessor and core.require_capability also enforces features; the typed sugar (cache/database/objectstore/taskqueue/notifier) routes through it.
The relational driver contract of the unit machinery: LazyUnitOfWorkContract (open_lazily / close) backs the lazy request unit (refused otherwise), the commit hooks ask DatabaseProvider.has_pending_changes(session) (no SQLAlchemy call in the kernel hooks), and the SQLAlchemy driver implements both and advertises write_admission. Verified on PostgreSQL/SQLAlchemy only.
sandloom new generates a consumer-owned app project (app/asgi on the kernel create_app, deploy files, CI + pre-commit running sandloom check, a manifest enforced by sandloom check) whose own tests and lint pass out of the box.
Packaging: a generated app depends on the sandloom distribution (compatible range, with its Redis/Celery drivers) and on one ecosystem package per module chosen with --with (sandloom-identity, ...: compatible ranges, no exact pin), every sandloom.* import of a generated project ships in its declared distributions, and the app reuses the kernel factory.
define_module(name, setup=..., router=..., events=...) calls setup(core) for contracts/consumers/projections, accepts explicit router/events overrides, supports modules without api/events, and infers the package from the caller frame as a fallback.
sandloom check [--only rule,...] [--list-rules] runs the stable set of architecture analyzers against an architecture_rules.yml manifest (framework or app), exiting 0 ok / 1 violations / 2 usage.
SANDLOOM_MODULES selects the module subset a process loads; every subset boots and becomes ready on its own, a blacklisted module is excluded, and a subset whose only module failed is not ready.
MODULE_REQUIRED makes a module's load failure abort startup; when empty, every module listed explicitly in SANDLOOM_MODULES is required, an explicit MODULE_REQUIRED wins, and a required dependent of a failed module aborts.
The sandloom.core.testing pytest plugin, activated explicitly with addopts -p sandloom.core.testing.plugin (no pytest11 entry point), loads .env.test before any conftest imports settings, refuses a non-test database and provides the client/db/make_headers fixtures, so app modules need no conftest.
migrations/env.py is run_module_migrations(file): the package, .models and the _ table prefix are derived from the env.py location, keywords override, and a non-package location is a clear error.
A route policy's atoms add the contracts they resolve to the module's derived consumes as optional requirements (SubscriptionActive / Credits the entitlements contract, Entitled the fact owner's facts.v1, Subscribed billing.facts.v1, OrgRole the membership contract).
identity.contact.v1 answers a user's contact details (email, name, email_verified, is_active) by id, locally and over RPC alike, so a module that writes to users needs no PII in events and no identity edit.
Module ecosystem: a module distribution registers its module in the sandloom.modules entry-point group (name = import path); its entry-point name is its module name (tables, migration chain, channels, contracts), SANDLOOM_MODULES / MODULE_REQUIRED / deploy profiles may name it by that name or by import path, and a requested name with two providers (two installed packages, or an installed package and a local module) is refused before anything loads (SANDLOOM-B020), naming both and the fix.
Distributions are layered and work installed alone: no module a distribution ships imports (module level, function-local or a constant-string dynamic import) a module of a distribution it does not depend on, nor a first-party module no distribution ships, nor an undeclared third-party package outside an ImportError guard; pip install sandloom alone (no module package, no Redis/Celery) runs every sandloom subcommand's --help, sandloom docs and sandloom new; sandloom[standard] composes the installed modules; an app's sandloom check treats an installed module package as another module.
sandloom migrate upgrade-core [--database x|--all-databases] --apply [--lock-timeout S] applies the core infra chain to a named database.
sandloom/core/tests/test_ops_cli.py::test_cli_lock_timeout_flag_reaches_the_lock gap (S): Only flag plumbing is tested; no test runs upgrade-core --database against a second database.
C-MIGRATE-04
sandloom migrate revision -m msg [--autogenerate] creates a revision in that module's own chain (its migrations/, version table and database), for app modules via SANDLOOM_COMPOSITION_ROOT; an unknown module fails cleanly.
sandloom migrate extract-module moves one module's existing data out of a shared database into its own (fence, migrate target, copy, verify, outbox/inbox hand-off, group-owner transfer), enabling monolith -> microservices with data.
A module's migrations/env.py is the one-liner run_module_migrations(file), deriving package, table prefix and version table by convention with explicit overrides.
sandloom migrate new autogenerates against a scratch database with every chain at head, so it needs no configured database at head (--against-database keeps that comparison) and loads the project's .env.test like sandloom test.
monotonic_upsert / monotonic_update apply a projection change only when the stored version is NULL or older than the incoming one, so at-least-once / out-of-order delivery never rolls a row back (tombstones block resurrection, and a tombstone purge never deletes one between an apply's watermark check and its write, as the apply holds the watermark row FOR SHARE until it commits).
Projection rows carry tenant_id: monotonic_upsert requires it and raises TenantMismatchError if it differs from the bound tenant; updates are scoped to the bound tenant.
sandloom projection list | rebuild [--tenant ] rebuilds a projection from source modules' read contracts, running startup tasks only (no consumers, no relay).
sandloom/core/tests/test_projections.py::test_rebuild_cli_does_not_start_consumers_or_relay examples/projecthub/projecthub/modules/taskboard/tests/test_projection.py::test_rebuild_refreshes_stale_project_fields gap (S): --tenant scoping of a rebuild is not exercised by a test.
C-PROJ-04
monotonic_upsert(insert_only=...) seeds columns this source does not own on INSERT and never touches them on UPDATE.
gap (S): No test (framework or examples) uses insert_only; a unit test proving INSERT seeds and UPDATE preserves the column is missing.
C-PROJ-05
A read module registers a QueryProjectionDescriptor, per-channel ProjectionConsumerDescriptors (durable consumers namespaced by the module) and a ProjectionRebuilder; a consumer for an unknown projection or a duplicate registration is rejected.
The projecthub taskboard pattern (version-guarded local source snapshot locked by both writers) keeps a denormalised field correct when a source event races a projection insert, and the projection is served by one query.
Experimental in 1.0 (owner decision 2026-10-26; relational model= targets are the supported 1.0 path). Projection(store=("document_store", name)) targets a store whose driver advertises conditional_write (put_if_newer / tombstone_if_newer, refused otherwise) and is read by View(...); the in-memory document_store driver (memory) advertises conditional_write and tenant_native and passes the conformance kit.
rebuild(name, tenant_id=) and sandloom projection rebuild run the projection's rebuilder with the rebuild marked in its watermark row (purges refuse meanwhile), without the consumer runtime; rebuild_all covers the tenants the projection holds, rebuild_entity(":") one aggregate, rebuild_from may name (contract, method); blue_green=True runs in place (no 1.0 store swaps an alias) and says so in the result.
A projection source group names its tenant field (tenant_from=, e.g. organization_id) and the consumer binds that tenant; a source whose version field is optional or which lacks the tenant field is refused at definition time with the alternative.
Delivery lattice laws (sandloom.core.guarantee_lattice): a guarantee's delivery set is closed under atomic => no_loss and no_dup; the meet is idempotent, commutative and associative, FULL is its unit on deliveries, and unknown properties are refused.
Labels are derived from the mechanism, one scenario per row of the label table: only a write-store write is atomic; an outbox event is at-least-once (no_loss only with a durable bus); a durable consumer effect is no_dup only with the inbox in its database and the inbox outliving the stream; an effect in another store is no_dup only through a conditional write; a command is no_dup only with a database command log whose TTL covers the retries (never the cache fallback); consumer delivery is always qualified (parks_*); a label is shown only where it deviates.
A conditional_write store passes conditional_write_conformance (older and duplicate versions never apply, tombstones are versioned and never resurrected, tenants isolated); the in-memory driver passes, a last-write-wins store fails.
sandloom outbox list-dead | requeue [--database] [--channel] [--id|--all] lists dead-lettered outbox rows and returns them to pending (the admin API requeue is audited atomically and idempotent).
Delivered outbox rows older than OUTBOX_RETENTION_HOURS are purged by the relay on a time cadence and on demand by sandloom outbox purge.
sandloom/core/tests/test_outbox_relay_batch.py::test_retention_purge_deletes_only_old_delivered_rows sandloom/core/tests/test_outbox_relay_purge_cadence.py::test_purge_runs_on_a_time_cadence_not_a_tick_count sandloom/core/tests/test_outbox_relay_purge_cadence.py::test_purge_stops_at_its_time_budget gap (S): The sandloom outbox purge CLI entry point itself is not invoked by a test.
C-OPS-03
sandloom streams dlq-list | dlq-replay [--id] [--group] inspects and replays Streams dead letters, the replay targeted at the group that dead-lettered the entry.
sandloom streams groups | consumers [--all] | release-group report group lag/pending, live consumers (flagging starved channel versions) and release a group.
sandloom backup plan lists every database and the security Redis to back up without secrets; sandloom backup verify restores a dump into a scratch database and checks it against code heads.
sandloom backup reconcile-security and sandloom security audit-export re-derive blocks/revocations from the audit log and outbox (newest record wins, a tie stays blocked) and flag a too-short outbox retention as a coverage gap.
deploy/compose/monolith.yml runs every module in one app process plus worker and beat on one database, every process env validating, with an explicit BILLING_PROVIDER.
deploy/compose/microservices.yml runs one module per service behind an nginx gateway, each with its own database and migrate one-shot, wired by SERVICE_BINDINGS that are exactly the consumed-but-not-local contracts, with RPC allowlists derived from the bindings.
Deploy profiles fit the Postgres connection budget, use one named image for every built service, a durable Redis bus, and (opt-in) a shared, durable idempotency Redis.
The image serves via gunicorn + maintained uvicorn workers with a cgroup-aware default worker count (WEB_CONCURRENCY overrides) and LB-safe keep-alive.
sandloom add deploy --service deploys a module whose required cross-module contracts are not bound yet (it reads the module's topology without the startup consumes preflight, which stays strict for applications), binds them to their providers' services, and gives a module with periodic tasks its -worker / -beat pair.
An sandloom new application splits into microservices with no code change. sandloom add deploy ... --service creates the app's microservices profile, nginx gateway and database init script from templates shipped in the package, then derives each service (its own database and migrate one-shot, RPC keys, worker and one beat), SERVICE_BINDINGS (plus the issuer's remote claims), RPC_ALLOWED_CALLS and the gateway routes from what the modules provide and consume. A repeated run changes nothing, a module is added to a service incrementally, hand edits outside the generated blocks are kept and an edit inside one is refused with a diff. The framework's own profile is the same generator's output. Route collection covers every route a service's app serves (round 3) - root-level routes (/ and /{slug} of module_router(prefix="")) get location = / and the catch-all location / (the internal surfaces then answering 404 explicitly), two services claiming the same route prefix, / or root-level parameter paths are refused with SANDLOOM-T014 naming them, never silently dropped, and a static catch-all left outside the generated routes block is refused. Each route prefix's locations stop at its segment boundary as Starlette matches (round 4) - location = /shop (to the service whose routes match the bare prefix) and location /shop/, never an unbounded location /shop, so /shopping stays with a root-level /{slug} service.
A microservices profile is checked against the split invariants the framework's own profile is pinned to (SANDLOOM-T014), in sandloom test's schema & deploy step for an application's profile too, and every HTTP service boots alone with exactly its environment (settings, startup preflight, composition root, every consumed contract bound) without network. The gateway check compares the gateway with every route each service's app serves (root-level / and /{slug} included), never a prefix-filtered list (round 3), and samples adversarial paths (round 4) - each path parameter also takes every literal segment a route has at that position, bare and suffixed - requiring the upstream to be the service whose app routes match the path (Starlette matching, the most literal route winning).
A service added to a running microservices stack gets its database although the Postgres volume exists (init-databases.sh runs only on an empty volume) - every generated migrate- one-shot runs sandloom migrate upgrade-all --apply --create-databases, which creates each hosting PostgreSQL database when missing (idempotent, with the citext and pg_trgm extensions), also in a profile generated before (the generated block carries the command). Race-safe (round 3) - provisioning is serialized per target name by an advisory lock on the maintenance database, and every caller ensures the extensions inside it whether or not it created the database, so a concurrent caller never returns (and migrates) before the database is complete.
Logs are structured JSON with request/trace context ids, JSON automatically in prod (LOG_FORMAT=auto), and logging init is idempotent across create_app calls.
Prometheus RED metrics are recorded per request, every safe_inc name resolves to a defined metric, and gauges declare a multiprocess mode so they render under gunicorn.
Outbox oldest-pending age (ignoring delivered/dead rows, refreshed even while the broker circuit is open) and Streams oldest-unacked age per group are exported.
SENTRY_DSN enables error reporting (no-op when unset) with a before_send scrubber.
sandloom/core/tests/test_ops_security_hardening.py::test_sentry_before_send_scrubs_event gap (S): No test asserts Sentry init is a no-op without SENTRY_DSN / initialises with it.
C-OBS-07
GC tuning (GC_FREEZE_AFTER_STARTUP / GC_THRESHOLDS / GC_METRICS_ENABLED) freezes the startup heap and applies thresholds in gunicorn and Celery workers (entry points only, never create_app), with a pause histogram.
The shipped alert/dashboard bundle references only existing metrics and labels, every alert has severity and a runbook entry matching docs/operate/operator-reference.md, and promtool rule tests pass.
The sandloom-admin wheel carries the build of the current console source and no application data, so an application serves it with neither Node nor a separate image.
sandloom generate writes the console's manifest and refuses a declaration the application's API does not support; a console that cannot show the manifest says why instead of breaking.
sandloom security grant-admin makes a registered user a platform administrator, verifies the address, keeps one assignment when repeated and writes a required audit row.
The sandloom.core.testing pytest plugin (-p sandloom.core.testing.plugin) loads .env.test before any settings import, honours a composition-root ini option and exposes the db/client/db_uow fixtures binding service effects to the test UoW.
SANDLOOM_TEST_DATABASE_URL / SANDLOOM_TEST_CACHE_URL / SANDLOOM_TEST_BROKER_URL win over .env.test on every load path (and are rewritten per xdist worker); reload_test_env reapplies the rewrite.
tools/checks/test_resources.py list|prune manages shared test DBs / Redis indexes: prune is a dry run without --apply and never touches a connected or recently used resource.
sandloom.sdk.testing.route_policy(route) reports what the framework applies to a route: auth (user / service / public), the public reason (deny-by-default is assertable from an app suite), the effect (read / write by method) and the risks resolved from their sources; assert_route_snapshot(app, *modules) fails unless each module's routes equal its committed routes.snapshot.json, naming the fix (SANDLOOM-T001), and fails on a missing snapshot.
published(E) lists the E events the code under test published with Event.publish(); a published() call without the testing plugin explains how to activate it; capture_effects also records a typed Event.publish() made from the listed modules.
deliver drives the real consumer runtime (schema preflight, the durable claim, the tenant binding), accepts an Event instance and keeps today's deliver_event parameters; as_user is make_user_headers.
Under the client fixture a route's session is the test's shared one however the route obtains it: a DbSession route and a db() route behave identically (writes visible to the test and to later requests, a failed request not rolled back, on_commit callbacks after a successful request only); real_uow_client is the real-commit path for both.
After every test the harness returns the pooled connections of the driver's default engines and of every database provider the active application built, so no later test (a new event loop) reuses a connection of a finished loop.
sandloom test never aborts for want of parallel resources - -n auto is capped to the Redis database indexes the run holds or could claim (serially below two) with a warning, and the xdist steps run serially with a notice when pytest-xdist is not installed.
sandloom test runs a static schema & deploy step (no database, no Redis) before the suites: migration-chain coverage read from the revisions' syntax tree (SANDLOOM-T002, a constant table name counts), redundant indexes (SANDLOOM-T012), one beat per module with periodic tasks in every deploy profile (SANDLOOM-T010), and a warning for a TenantScoped key without tenant_id (SANDLOOM-T013).
fake_contract(REF, impl) also serves the contract to kernel code that resolves it through the active application (core.get_service - the billing and membership authz atoms), and fake_access_fact on the subscription facts drives the live SubscriptionActive / Subscribed(of=Account.org(...)) atoms, with or without billing loaded.
celery -A sandloom.core.worker worker\|beat boots the composition root and imports .tasks (and setup_periodic_tasks) of every LOADED module only, skipping modules without tasks.
The worker takes its Celery app from the taskqueue capability instance named by SANDLOOM_WORKER_TASKQUEUE, rejecting a non-Celery taskqueue and a composition root without a core; importing sandloom.core.worker has no side effects.
run_async runs task coroutines on one persistent loop per worker process (fresh after fork, refused inside a running loop), draining the dispatcher and disposing engines on that loop at shutdown.
The beat lease is scoped per service (replicas of one service share it, separate services do not) and wraps only this app's scheduler; the worker boot runs the tenant RLS role verification.
Modules declare linear, versioned sagas (Saga[InputT], step, local_step) with typed context and results, and start them with start_saga / read them with saga_status.
Operators inspect and drive sagas with sandloom sagas list|show|retry|park|resolve (actionable default view, timeline with remediation) and sandloom doctor warns about parked instances; definitions appear in /diagnostics/core.
Event / on: typed construction, publish() through the outbox of the running unit (recorded for published()), meta before delivery raises EventMetaUnavailable.
contract / command / Contract: a stateless provider class becomes the L2 classmethod(contract_method(...)) form with every option (idempotency_key, idempotency_scope, database, planned); init, instance attributes, non-async or public non-method members and bases outside the package are refused (SANDLOOM-B018); Contract(REF) resolves when called (fakes, then the application resolver); typed saga steps name the command; @command(risk="money") is passed to contract_method(risk=) (the contract snapshot records it), and Contract(REF) resolves through the active core (sandloom.core.di.get_service) outside the test plugin.
module_router: derived prefix and tags, no router-level auth; per route current_context and TenantScope(required=...) unless public() or a service principal; a route that binds the tenant itself gets no second binder; public() with an authenticator is refused (SANDLOOM-B008); tenant="derive" is required iff the module declares a TenantScoped model; a route with a service authenticator stamped by sandloom.sdk.web.authenticates(kind="service") gets nothing injected.
Projection (relational targets): key starts with tenant_id, one owning group per column, one version column per group, delta mappers only under the durable inbox, tombstones need their column (SANDLOOM-B012); full-state events converge under shuffles and duplicates, tombstones beat older upserts, deltas increment, views never return tombstones.
db() is the running unit's session wherever a unit runs: the request unit (lazy), open_uow() blocks, consumers, tasks and saga steps, and inside a contract method the contract's session (contract_session()), also under bind_contract_session.
sandloom docs answers a public name's home, tier, signature and documentation (and a module's names by tier) without a kernel read; every beginner code name defined in sandloom.sdk documents an example.
Every public sandloom.sdk module starts with a generated reference block (beginner and advanced names, doc page) that is current (sandloom docs --write).
sandloom docs describes what an app calls besides sandloom.sdk names, from the installed packages alone (the bundled sandloom.shared and the app's .shared): sandloom docs contracts / events list the generated stubs and exported schemas; sandloom docs (name or REF constant) prints the import and each method's signature and the provider's docstring; sandloom docs prints the fields, the producer's notes (versions, tenant field, ordering field) and a view to copy. Every method and class of a bundled contract stub states its semantics.
The sandloom docs recipes with whole files (test-service-or-task, call-contract, projection-from-platform-events, event-v2, periodic-task) run as written in a generated app: their sandloom add / migrate new lines and # file: blocks in text order, then sandloom generate and sandloom test on the whole app and sandloom docs from the installed packages (tools/smoke/recipes_check.py, CI job app-recipes).
sandloom.sdk ships the id and timestamp idiom: new_id() returns an RFC 9562 UUIDv7 (version 7, RFC 4122 variant, the current Unix millisecond) that strictly increases within a process (a burst in one millisecond, a clock stepping back, a full counter) and orders by time across processes; CreatedAt / UpdatedAt map timestamptz NOT NULL columns defaulting to clock_timestamp() (UpdatedAt also on update), compose with TenantScoped, autogenerate as server_default=sa.text('clock_timestamp()'), and rows one transaction inserts list in creation order by created_at then id. sandloom docs describes them and the list-order recipe; the bundled modules' chains migrate every such column to the clock_timestamp() default.
sandloom.sdk.tasks.shared_task is Celery's decorator typed with the task's signature (call, delay, apply_async, s), so a task module type-checks under strict mypy with no project override; cron() builds a beat schedule from a five-field expression.
sandloom add module writes new files inside the package only (model, schemas, services, events, module_router routes, a baseline migration with RLS rendered from the model, tests) and refuses an existing package or a bad name.
sandloom add contract writes the provider in contracts.py and its DTO in the module's own schemas (the provider authors its DTOs; a second contract of the module joins the same schemas import), and writes the stub and the DTO copy at once exactly as sandloom generate renders them (so a saga or caller in the same scaffold boots); a generated stub imports ContractRef from its kernel home sandloom.core.descriptors.contract_refs (no sandloom.sdk import, no advanced marker), names the generated DTO copies, passes its Protocol, and is ruff format clean.
sandloom add consumer writes a view from the producer schema (the tree or the exported file); without one it needs --field and marks the view unverified.
sandloom add projection writes the projection, its row model and a migration chained after the module chain head; a fully generated app (every generator) passes ruff, format, mypy --strict and sandloom check with the pending 1.0 checks, and every one of its tests passes.
sandloom migrate new adds enable_tenant_rls / disable_tenant_rls (system_access for system_access models) for every TenantScoped table the autogenerated revision creates.
sandloom add module wires the module where its project loads modules (the framework tree's LOCAL_MODULES, SANDLOOM_MODULES lists, the monolith profile; an sandloom new app discovers it) and, in the framework tree, gives its tests the kit's fixtures (client, published recording) through a one-line conftest.
sandloom add task [--periodic ""] writes an idempotent at-least-once task (acks_late, reject_on_worker_lost, run_async with an open_uow block) that passes the app's static gates, registers a periodic one in the module's setup_periodic_tasks, and wires the beat or prints the step.
The quickstart sandloom new prints is the documented one (one source) and passes as printed in an empty directory - uv sync, sandloom add module, sandloom test, migrations, the app serving /health and an authenticated round trip, sandloom test --ci - with the dev services' ports configurable.
sandloom add module generates a list route (GET /items) whose limit is capped with Query(le=...) over its TenantScoped table, newest first in a deterministic order (the model uses the framework's idiom - id defaults to new_id, a UUIDv7, and created_at is Mapped[CreatedAt], which defaults to clock_timestamp(), so rows created in one transaction do not tie - and the list orders by created_at then id), and a test that covers the order of rows created in one transaction, the page size, the cap (422) and another tenant's empty list.
sandloom new --framework-source path writes editable sources, and an editable install copies no sandloom file (its .pth puts the live src/sandloom on sys.path), so the app always runs the checkout's current tree, never a cached build of the same version; the quickstart job fails when the installed framework is not the checkout.
sandloom new writes the app's agent skills (sandloom-new-module, sandloom-quality-gates, sandloom-event-version-roll, sandloom-split-deploy), rewritten for the app's paths and commands, to .claude/skills and .agents/skills, and the app map names them in one line; sandloom add skills writes them into an existing app, keeping an edited file unless --force.
Example apps pass their own gate - every example app (examples/) and the tutorial app (docs/tutorial-app/) passes the gate an app developer runs, sandloom test --ci --full (generated files checked, schema & deploy, the full suite, ruff check without fixing, ruff format, mypy, sandloom check, contract and route snapshots, migration drift), in the CI examples job, which lists every such app; a generated contract stub is exactly what ruff format writes.
sandloom generate [module ...] writes every generated file - module, route and contract snapshots, contract stubs, generated DTO copies, exported event schemas - after the compatibility checks (an incompatible change fails with its code and nothing is written; a weaker route policy is refused, pointing to sandloom routes snapshot --write); module names narrow it to those modules' files. sandloom test never writes a generated file, dev and --ci alike: a stale or hand-edited one fails, listed under "generated files are stale: run sandloom generate". Every sandloom add generator (but skills and deploy) runs sandloom generate last, and sandloom new writes them through it, so the files are current after scaffolding.
No test proves that publish_event_versions inside a UoW writes all versions as outbox rows atomically (rolled back together).
C-INFRA-05
S
no test resolves a Capability(...) dependency in a request and asserts it yields the named provider (or the contract/feature error)
C-INFRA-10
S
no test calls the /diagnostics/infra endpoint itself (only registry.describe(); the route appears only in the operation-class map)
C-MIGRATE-03
S
Only flag plumbing is tested; no test runs upgrade-core --database against a second database.
C-OBS-06
S
No test asserts Sentry init is a no-op without SENTRY_DSN / initialises with it.
C-OPS-02
S
The sandloom outbox purge CLI entry point itself is not invoked by a test.
C-PROJ-03
S
--tenant scoping of a rebuild is not exercised by a test.
C-PROJ-04
S
No test (framework or examples) uses insert_only; a unit test proving INSERT seeds and UPDATE preserves the column is missing.
C-TENANT-02
S
TenantScope(required=False) yielding None and leaving the context unbound, and custom header/claim_keys, are untested.
G-AUDIT-01
S
No generic test that a plain record_audit followed by a UoW rollback leaves no audit_log row; only the block_user path proves it.
G-AUDIT-02
S
The 'no audit store' branch of AuditUnavailableError (database driver without the audit feature) has no direct raising test (test_audit_store_skips_unsupported_backend only checks the store is absent).
G-COMPAT-03
S
No test drives sandloom contracts check --against http://<provider> (live describe document over the internal token) or the sandloom contracts snapshot --write/--check CLI exit codes (only the underlying functions are tested).
G-COMPAT-07
S
The startup log for a remote binding with no snapshot at all ("unchecked", CONTRACT_SNAPSHOT_PATHS absent) is not asserted.
G-CONFIG-05
S
sandloom_config_override_active{setting,kind} metric export (app_factory) is not asserted by any test.
G-CONFIG-08
S
AGENTS.md and accepted_risks error messages point at docs/reference/configuration-tiers.md, but that file does not exist in the repo (only tiers.render_markdown() in memory); nothing checks the committed page exists / is current.
G-EFFECT-03
S
No test proves that a before_commit check registered inside a rolled-back savepoint is skipped (ADR §3 stage 1).
G-EVENT-05
S
No test asserts the handler's DB writes are rolled back when the handler raises (the consumer UoW rollback itself); tests check redelivery with fake sessions only.
G-GUARD-01
S
Tests call the order checker directly; no test boots create_app with a misordered route to prove the startup wiring.
G-INFRA-06
S
no test asserts the preflight performs no I/O (opens no provider/connection), nor that a failed required preflight aborts create_app for a required module
G-MIGRATE-05
S
Doc drift: AGENTS.md lists four core tables (outbox/audit/consumer_inbox/contract_command_log) but the chain (and the test) also own http_idempotency_log.
G-MIGRATE-06
S
The prod refusal of ensure-outbox --apply without --force (cli/migrate.py) is untested.
G-MODULE-03
S
no test proves a module importing sandloom.core.module_api._service_registry or sandloom.core.facade._state is flagged (only that the tree is clean)
G-OPS-12
S
The prod --confirm-production requirement of sandloom security is untested (only sandloom backup's guard rails test it).
G-OUTBOX-04
S
No test calls publish_event INSIDE a UoW with an invalid channel / schema-violating / oversized payload and asserts the call raises and no outbox_messages row exists; the in-UoW path (unit_of_work/effects.py preflight) is only covered indirectly.
G-PROFILE-05
S
enforce_runtime is tested directly, but no test proves it is wired as a critical startup task of create_app's lifespan or of the Celery worker boot (removing either call site would stay green).
G-PROFILE-06
S
The 'Cannot attest separate ... Redis processes' failure (deployment_identity raising) has no test.
G-REVOKE-06
S
No test pins that password change/reset and user deactivation use the immediate (pre-commit) path specifically, rather than only that tokens end up revoked.
G-RISK-02
S
Export of sandloom_accepted_risk_active{risk}=1 (app_factory) is never asserted; a test reading the metric after create_app in prod with a risk accepted is missing.
G-RISK-11
S
No test exercises the unversioned_ddl_repair gate in sandloom/core/cli/migrate.py (only ensure-outbox dry-run / dev apply are tested).
G-RLS-05
S
The BYPASSRLS-attribute branch (and membership in a BYPASSRLS/superuser role) has no direct test; only SUPERUSER, table ownership and system-role membership are exercised.
G-RLS-08
S
'No audit row, no commit' (a failing audit write aborts the system session's commit) and the structured start/end log record (op_id, outcome, duration) are not tested.
G-TENANT-14
S
The 'token tenant does not match the configured tenant' 403 (token claim, no header) branch has no test.
G-TENANT-17
S
The rule is only tested at the analyzer level (find_unsafe_tenant_sql); no test runs it through run_checks/sandloom check on a module fixture as the tenant-scoped-models rule is.
G-UOW-06
S
Only the private resolver is unit-tested; no end-to-end test holds DbSession + Database("x") in one endpoint and asserts the event lands in the chosen database's outbox_messages (and is marked delivered there).
G-UOW-07
S
Non-atomicity itself is a documented non-guarantee; no test pins that a failing second-database UoW leaves the first committed (i.e. that nobody quietly introduces a coupled commit).
G-BLOCK-04
M
No generic check forces a custom (app-defined) issuer to call ensure_user_not_blocked; the rule is documentation-only for third-party issuers.
G-MODULE-17
M
convention only: no sandloom check rule or test rejects a ForeignKey from one module's table to another module's table prefix
sandloom.core.testing.assert_operation_classes(app) fails an app with an unclassified or contradictorily classified route; sandloom new generates the check.
superseded by C-TEST-07 (ADR-0009, accepted by the owner; spec §9.2): ADR-0009 removes assert_operation_classes outright; apps assert deny-by-default with sandloom.sdk.testing.route_policy(route) and pin their routes with assert_route_snapshot (C-TEST-07).
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-GUARD-03
create_app refuses (FinancialGuardError) an operation_class('F') route with a response-storing cache guard or a fail-open guard.
superseded by G-SENS-05 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-IDEM-13
create_app refuses an operation_class("F") route whose idempotency guard is the response-storing cache form or fail-open (FinancialGuardError); durable=True or store_response=False + fail_closed=True are accepted.
superseded by G-SENS-05 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-OPCLASS-01
Every API route of the composed monolith (kernel ops routes + identity + tenancy + billing) declares exactly one ADR-0003 operation class (P/R/RS/W/F/A) on the route or its router; an unclassified route fails CI, and apps run the same gate via sandloom.core.testing.assert_operation_classes(app).
superseded by G-ROUTE-01 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-OPCLASS-02
create_app refuses (OperationClassError) a route with two different classes, an unknown class, a revocation_freshness budget outside class R, or a class RS/W/F/A route that is not always-strict.
superseded by G-ROUTE-01 / G-ROUTE-04 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-REVOKE-13
revocation_freshness(max_staleness_s=N) requires 0 < N <= 5 (else ValueError) and may be declared only on GET/HEAD routes: create_app refuses a budget on any other method or on a router with a write route (RevocationPolicyError).
superseded by G-REVOKE-21 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-REVOKE-14
A budgeted route answers from a per-process mark copy only while it is younger than the budget at DECISION time (re-checked after the live block read); a copy is never extended, and a copy expired or invalidated while a read was in flight is replaced by a live read of the mark.
superseded by G-REVOKE-21 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-REVOKE-15
revoke_user_tokens drops the local copy and broadcasts on Redis pub/sub (sandloom:auth:revocations); other processes drop theirs, a lost broadcast delays a revocation on budgeted routes by at most the budget, only a drop of the same subject (or a wholesale clear on (re)subscribe) discards an in-flight read.
superseded by G-REVOKE-21 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-REVOKE-16
A route marked always-strict (Depends(strict_revocation()) on it or its router, or a path under AUTH_REVOCATION_STRICT_PATH_PREFIXES) ignores a revocation_freshness budget (logged at startup) and always reads the mark live.
superseded by G-REVOKE-21 (ADR-0009, accepted by the owner; spec §9.2)
owner (ADR-0009 / ADR-0010 approval, 2026-10-25)
2026-10-26
G-REVOKE-20
Per-request revocation cost: a strict route does one Redis round trip (MGET of revocation + block marks); a budgeted route with a fresh local copy does one GET of the block mark.
superseded by G-REVOKE-21 (ADR-0009, accepted by the owner; spec §9.2)
docs/reference/security-model.md#What is guaranteed (tables with enable_tenant_rls, startup verification passed) docs/reference/security-model.md#SQL paths available to business-module code
G-RLS-02
docs/reference/security-model.md#What is guaranteed (tables with enable_tenant_rls, startup verification passed)
G-RLS-03
docs/reference/security-model.md#What is guaranteed (tables with enable_tenant_rls, startup verification passed) docs/modules/database/multi-tenancy.md
G-RLS-04
docs/reference/security-model.md#What is guaranteed (tables with enable_tenant_rls, startup verification passed)
G-RLS-05
docs/reference/security-model.md#What is guaranteed (tables with enable_tenant_rls, startup verification passed) docs/modules/database/multi-tenancy.md
G-RLS-06
docs/modules/database/multi-tenancy.md
G-RLS-07
docs/modules/database/multi-tenancy.md
G-RLS-08
docs/reference/security-model.md#The system channel (privileged work) docs/modules/database/multi-tenancy.md
G-RLS-09
docs/reference/security-model.md#The system channel (privileged work)
G-RLS-10
docs/reference/security-model.md#SQL paths available to business-module code docs/modules/database/multi-tenancy.md
G-RLS-11
docs/reference/security-model.md#SQL paths available to business-module code docs/reference/security-model.md#What is NOT guaranteed (residual risks)
G-RLS-12
docs/reference/security-model.md#SQL paths available to business-module code
G-RLS-13
docs/reference/security-model.md#What is guaranteed (tables with enable_tenant_rls, startup verification passed)
G-RLS-14
docs/reference/security-model.md#What is NOT guaranteed (residual risks) docs/reference/security-model.md#Trust model of this Sandloom version (normative)
G-RLS-15
docs/reference/security-model.md#Tenant RLS threat model
docs/reference/security-model.md#PRODUCTION_PROFILE=multi_tenant_strict docs/reference/security-model.md#The contract (what Sandloom guarantees, and under which conditions)
G-PROFILE-04
docs/reference/security-model.md#Production profile: the multi-tenant trust model as a contract
docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle tests/fixtures/specs/public-api-1.0-spec.md#4.1 Units tests/fixtures/specs/public-api-1.0-spec.md#4.3 db() ownership
G-UNIT-02
docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle tests/fixtures/specs/public-api-1.0-spec.md#4.6 Operation-intent mode (billing checkout and portal)
G-UNIT-03
docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle tests/fixtures/specs/public-api-1.0-spec.md#4.4 Write admission: one writer per request
G-MSTORE-01
docs/adr/0010-public-api-1.0.md#5. One writer per request tests/fixtures/specs/public-api-1.0-spec.md#6.1 Write store
G-MSTORE-02
docs/adr/0010-public-api-1.0.md#5. One writer per request tests/fixtures/specs/public-api-1.0-spec.md#6.1 Write store
G-MSTORE-03
docs/adr/0010-public-api-1.0.md#5. One writer per request tests/fixtures/specs/public-api-1.0-spec.md#4.4 Write admission: one writer per request
G-MSTORE-04
docs/adr/0010-public-api-1.0.md#5. One writer per request tests/fixtures/specs/public-api-1.0-spec.md#4.4 Write admission: one writer per request
G-MSTORE-05
docs/adr/0010-public-api-1.0.md#5. One writer per request tests/fixtures/specs/public-api-1.0-spec.md#6.1 Write store
G-MSTORE-06
docs/adr/0010-public-api-1.0.md#5. One writer per request tests/fixtures/specs/public-api-1.0-spec.md#6.1 Write store
G-MSTORE-07
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005) tests/fixtures/specs/public-api-1.0-spec.md#6.4 Projection convergence model
G-MSTORE-08
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005) tests/fixtures/specs/public-api-1.0-spec.md#6.4 Projection convergence model
G-MSTORE-09
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005) tests/fixtures/specs/public-api-1.0-spec.md#6.4 Projection convergence model
G-MSTORE-10
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005) tests/fixtures/specs/public-api-1.0-spec.md#6.4 Projection convergence model
docs/modules/index.md#What you get by default, and what you declare docs/adr/0005-effect-model.md#4. Mechanism matrix
G-OUTBOX-06
docs/modules/database/units.md
G-OUTBOX-07
docs/adr/0005-effect-model.md#3. The commit pipeline — one stage list docs/adr/0005-effect-model.md#4. Mechanism matrix
G-OUTBOX-08
docs/adr/0005-effect-model.md#3. The commit pipeline — one stage list
G-OUTBOX-09
docs/modules/index.md#What you get by default, and what you declare
G-OUTBOX-10
docs/modules/index.md#What you get by default, and what you declare docs/adr/0005-effect-model.md#4. Mechanism matrix docs/operate/operator-reference.md#Events: outbox relay and dead letters
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Idempotency
G-IDEM-02
docs/modules/index.md#What you get by default, and what you declare docs/adr/0005-effect-model.md#4. Mechanism matrix
G-IDEM-03
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Idempotency
G-IDEM-04
docs/modules/index.md#What you get by default, and what you declare docs/adr/0005-effect-model.md#2. The dedupe record — one primitive
G-IDEM-05
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Idempotency
G-IDEM-06
docs/reference/security-model.md#Idempotency docs/modules/index.md#What you get by default, and what you declare
G-IDEM-07
docs/modules/index.md#What you get by default, and what you declare
G-IDEM-08
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Idempotency
G-IDEM-09
docs/modules/index.md#What you get by default, and what you declare docs/adr/0005-effect-model.md#2. The dedupe record — one primitive docs/adr/0005-effect-model.md#4. Mechanism matrix
G-IDEM-10
docs/adr/0005-effect-model.md#2. The dedupe record — one primitive
docs/adr/0005-effect-model.md#2. The dedupe record — one primitive docs/adr/0005-effect-model.md#Consequences
G-IDEM-14
docs/modules/index.md#What you get by default, and what you declare
G-IDEM-15
docs/reference/security-model.md#Idempotency
G-IDEM-16
docs/modules/index.md#What you get by default, and what you declare docs/modules/database/multi-tenancy.md
G-IDEM-17
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare) docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle
G-IDEM-18
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare) docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle
docs/modules/background/tasks.md docs/modules/index.md#What you get by default, and what you declare
G-TASK-05
docs/modules/index.md#What you get by default, and what you declare
G-WINDOW-01
docs/adr/0005-effect-model.md#6a. Declared requirements and guarantee windows (startup checks)
G-WINDOW-02
docs/adr/0005-effect-model.md#6a. Declared requirements and guarantee windows (startup checks)
G-WINDOW-03
docs/adr/0005-effect-model.md#6a. Declared requirements and guarantee windows (startup checks) docs/adr/0005-effect-model.md#4. Mechanism matrix
G-EVENT-01
docs/modules/index.md#What a module may import
G-EVENT-02
docs/modules/index.md#What a module may import
G-EVENT-03
docs/modules/index.md#What a module may import
G-EVENT-04
docs/modules/index.md#What a module may import
G-EVENT-05
docs/modules/index.md#What a module may import
G-EVENT-06
docs/modules/index.md#What a module may import docs/adr/0005-effect-model.md#4. Mechanism matrix
G-EVENT-07
docs/modules/index.md#What a module may import
G-EVENT-08
docs/modules/index.md#What a module may import
G-EVENT-09
docs/modules/index.md#What a module may import
G-EVENT-10
docs/modules/index.md#What you get by default, and what you declare docs/operate/operator-reference.md#Events: outbox relay and dead letters
G-EVENT-11
docs/modules/index.md#What you get by default, and what you declare
G-EVENT-12
docs/modules/index.md#What you get by default, and what you declare
G-EVENT-13
docs/modules/index.md#What you get by default, and what you declare docs/modules/between/events.md#Changing an event
G-EVENT-14
docs/reference/cli.md
G-AUDIT-01
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Audit trail docs/adr/0005-effect-model.md#4. Mechanism matrix
G-AUDIT-02
docs/modules/index.md#What you get by default, and what you declare docs/adr/0005-effect-model.md#4. Mechanism matrix
G-AUTH-01
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-AUTH-02
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-02
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-03
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-04
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-05
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-06
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-07
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-08
docs/reference/security-model.md#Authentication
G-REVOKE-09
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-REVOKE-10
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks
G-REVOKE-11
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0003-fewer-sync-rpc-membership-reads.md#Failure rules (fail-closed defaults) docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-REVOKE-12
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0003-fewer-sync-rpc-membership-reads.md#Decisions so far docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-REVOKE-17
docs/modules/database/units.md docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Guarantees, precisely docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-REVOKE-18
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-REVOKE-19
docs/reference/security-model.md#Security-critical vs best-effort cache docs/reference/cli.md
G-REVOKE-21
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare) docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix)
G-REVOKE-22
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare) docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix) docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle
G-REVOKE-23
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-REVOKE-24
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-BLOCK-01
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-BLOCK-02
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks
G-BLOCK-03
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks
G-BLOCK-04
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0003-fewer-sync-rpc-membership-reads.md#Operation classes (normative)
G-BLOCK-05
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
G-BLOCK-06
docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks
G-BLOCK-07
docs/adr/0009-route-policy-and-session-freshness.md#One subject-state record replaces the two marks
G-OPCLASS-03
docs/adr/0003-fewer-sync-rpc-membership-reads.md#Platform routes → classes docs/adr/0003-fewer-sync-rpc-membership-reads.md#Decisions so far docs/adr/0009-route-policy-and-session-freshness.md#The route snapshot
G-OPCLASS-04
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-OPCLASS-05
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
G-OPCLASS-06
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved docs/adr/0003-fewer-sync-rpc-membership-reads.md#Guarantees, precisely docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
G-OPCLASS-07
docs/adr/0003-fewer-sync-rpc-membership-reads.md#Platform owner console routes docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
G-ROUTE-01
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare) docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix) docs/adr/0009-route-policy-and-session-freshness.md#Removed from the internal API before the initial public alpha
G-ROUTE-02
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-ROUTE-03
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-ROUTE-04
docs/adr/0009-route-policy-and-session-freshness.md#The route snapshot docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix)
docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
G-SENS-02
docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
G-SENS-03
docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
G-SENS-04
docs/adr/0009-route-policy-and-session-freshness.md#What risk triggers (applied by the framework)
G-SENS-05
docs/adr/0009-route-policy-and-session-freshness.md#What risk triggers (applied by the framework) docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix)
G-SENS-06
docs/adr/0009-route-policy-and-session-freshness.md#What risk triggers (applied by the framework) docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix)
G-AUTHZ-01
docs/modules/index.md#What you get by default, and what you declare docs/adr/0003-fewer-sync-rpc-membership-reads.md#Failure rules (fail-closed defaults) docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects
G-AUTHZ-02
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authorization docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects
G-AUTHZ-03
docs/modules/index.md#What you get by default, and what you declare docs/adr/0003-fewer-sync-rpc-membership-reads.md#What must stay strict (always a live RPC / local call) docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects
G-AUTHZ-04
docs/modules/index.md#What you get by default, and what you declare
G-AUTHZ-05
docs/modules/index.md#What you get by default, and what you declare
G-AUTHZ-06
docs/reference/security-model.md#Authorization
G-AUTHZ-07
docs/reference/security-model.md#Authorization
G-AUTHZ-08
docs/reference/security-model.md#Audit trail
G-AUTHZ-09
docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects tests/fixtures/specs/public-api-1.0-spec.md#7.2 of= (required, no default)
G-AUTHZ-10
docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects tests/fixtures/specs/public-api-1.0-spec.md#7.2 of= (required, no default)
docs/reference/security-model.md#Transport & request hardening docs/contributing/repository.md#Things that look wrong but are intentional
G-HTTP-11
docs/reference/security-model.md#Secrets
G-HTTP-12
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
G-BILLING-01
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle
G-BILLING-02
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved docs/adr/0003-fewer-sync-rpc-membership-reads.md#Guarantees, precisely docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle
G-BILLING-03
docs/adr/0003-fewer-sync-rpc-membership-reads.md#Guarantees, precisely docs/adr/0003-fewer-sync-rpc-membership-reads.md#Strict RPC is not zero staleness: the TOCTOU window
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved docs/adr/0003-fewer-sync-rpc-membership-reads.md#Webhook matrix
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved
G-BILLING-11
docs/adr/0003-fewer-sync-rpc-membership-reads.md#Webhook matrix docs/contributing/repository.md#Things that look wrong but are intentional
docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0003-fewer-sync-rpc-membership-reads.md#Gaps the classification exposed — resolved docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle
G-BILLING-20
docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects tests/fixtures/specs/public-api-1.0-spec.md#7.4 Account selection in billing itself
G-BILLING-21
docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects tests/fixtures/specs/public-api-1.0-spec.md#7.4 Account selection in billing itself
docs/modules/between/contracts.md docs/operate/service-rpc.md#Request authentication and idempotency
G-CONTRACT-19
docs/modules/between/contracts.md docs/adr/0010-public-api-1.0.md#Note (2026-10-28): contract DTOs authored in the provider, generated into the DTO package
docs/adr/0004-rpc-pair-mac-request-authentication.md#Key schedule docs/operate/service-rpc.md#Request authentication and idempotency
G-RPC-02
docs/adr/0004-rpc-pair-mac-request-authentication.md#Key schedule docs/adr/0004-rpc-pair-mac-request-authentication.md#Threat model
G-RPC-03
docs/adr/0004-rpc-pair-mac-request-authentication.md#Request MAC
G-RPC-04
docs/adr/0004-rpc-pair-mac-request-authentication.md#Request MAC docs/adr/0004-rpc-pair-mac-request-authentication.md#Errors docs/operate/service-rpc.md#Request authentication and idempotency
G-RPC-05
docs/adr/0004-rpc-pair-mac-request-authentication.md#Request MAC docs/adr/0004-rpc-pair-mac-request-authentication.md#Threat model docs/reference/security-model.md#Service-to-service trust (internal RPC)
G-RPC-06
docs/adr/0004-rpc-pair-mac-request-authentication.md#Replay docs/adr/0004-rpc-pair-mac-request-authentication.md#Third review (re-review of the KCI fix, 2026-10-21)
docs/adr/0004-rpc-pair-mac-request-authentication.md#Key-compromise impersonation (KCI) and the command signature docs/reference/security-model.md#Service-to-service trust (internal RPC)
G-RPC-14
docs/adr/0004-rpc-pair-mac-request-authentication.md#Key-compromise impersonation (KCI) and the command signature docs/adr/0004-rpc-pair-mac-request-authentication.md#Key rotation
docs/modules/between/contracts.md docs/operate/service-rpc.md#Response binding docs/reference/security-model.md#Response binding — threat model
G-RPC-20
docs/adr/0004-rpc-pair-mac-request-authentication.md#Request MAC docs/adr/0004-rpc-pair-mac-request-authentication.md#Security review docs/reference/security-model.md#Response binding — threat model
G-RPC-21
docs/operate/service-rpc.md#Response binding docs/reference/security-model.md#Response binding — threat model docs/adr/0004-rpc-pair-mac-request-authentication.md#Amendment (2026-10-25)
docs/infrastructure/index.md docs/adr/0001-framework-distribution.md#Verification of current boundaries (from the codebase)
G-KERNEL-08
docs/adr/0007-kernel-layers.md#Principles
G-KERNEL-09
docs/modules/database/multi-tenancy.md
G-KERNEL-10
docs/modules/index.md#What a module may import docs/adr/0001-framework-distribution.md#Verification of current boundaries (from the codebase)
G-KERNEL-11
docs/adr/0001-framework-distribution.md#3. Compatibility contract docs/adr/0001-framework-distribution.md#Implementation status docs/adr/0007-kernel-layers.md#What moved (no re-exports at the old paths)
G-KERNEL-12
docs/reference/testing.md
G-KERNEL-13
docs/modules/index.md#What you get by default, and what you declare docs/adr/0001-framework-distribution.md#Boundary remediations required before the split docs/adr/0001-framework-distribution.md#4. Assembly
docs/reference/cli.md docs/adr/0006-accepted-risks.md#Decision docs/adr/0006-accepted-risks.md#Amendment (2026-10-25) docs/adr/0009-route-policy-and-session-freshness.md#Removed from the internal API before the initial public alpha
tests/fixtures/specs/public-api-1.0-spec.md#2.4 Import isolation docs/adr/0010-public-api-1.0.md#1. One public namespace, sandloom.sdk, with a beginner tier
G-SDK-02
tests/fixtures/specs/public-api-1.0-spec.md#3. Three API levels docs/adr/0010-public-api-1.0.md#2. Three API levels; level 1 is built only from level 2
G-SDK-03
tests/fixtures/specs/public-api-1.0-spec.md#3. Three API levels docs/adr/0010-public-api-1.0.md#2. Three API levels; level 1 is built only from level 2
docs/modules/between/contracts.md docs/adr/0010-public-api-1.0.md#Note (2026-10-28): contract DTOs authored in the provider, generated into the DTO package
G-MODULE-19
tests/fixtures/specs/public-api-1.0-spec.md#5.7 The application manifest and discovery
G-MIGRATE-12
tests/fixtures/specs/public-api-1.0-spec.md#5.6 Generated migrations with RLS tests/fixtures/specs/public-api-1.0-spec.md#5.8 sandloom test
G-AUTHZ-13
tests/fixtures/specs/public-api-1.0-spec.md#8.3 The authorization algebra is unchanged (Q4 revised twice) tests/fixtures/specs/public-api-1.0-spec.md#8.2 Property-based (Hypothesis)
G-SENS-07
docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner tests/fixtures/specs/public-api-1.0-spec.md#8.2 Property-based (Hypothesis)
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authentication
C-AUTH-05
docs/modules/standard/identity.md#Behaviour API clients must know
C-AUTH-06
docs/modules/index.md#What you get by default, and what you declare docs/reference/security-model.md#Authorization docs/adr/0010-public-api-1.0.md#7. Explicit authorization subjects
C-AUTH-07
docs/modules/database/units.md docs/reference/security-model.md#Authority at commit (admin and money effects) docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare) tests/fixtures/specs/public-api-1.0-spec.md#2.3 New names: exact signatures
C-ROUTE-01
docs/adr/0009-route-policy-and-session-freshness.md#Defaults every route gets (nothing to declare)
C-ROUTE-02
docs/adr/0009-route-policy-and-session-freshness.md#Risk is declared at its source, once, by its owner
C-UNIT-01
docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle tests/fixtures/specs/public-api-1.0-spec.md#4.3 db() ownership
C-UNIT-02
docs/adr/0010-public-api-1.0.md#4. Transaction and replay lifecycle tests/fixtures/specs/public-api-1.0-spec.md#4.3 db() ownership
C-SPLIT-01
docs/operate/topologies.md deploy/README.md#How the split works
C-SPLIT-02
docs/operate/topologies.md docs/operate/service-rpc.md#Resolution order
C-SPLIT-03
docs/operate/topologies.md
C-SPLIT-04
docs/operate/topologies.md deploy/README.md#Probes (liveness vs readiness)
C-SPLIT-05
deploy/README.md#How the split works
C-SPLIT-06
docs/operate/topologies.md docs/operate/topologies.md deploy/README.md#How the split works
C-SPLIT-07
docs/modules/index.md#What you get by default, and what you declare docs/operate/topologies.md
docs/infrastructure/index.md docs/adr/0010-public-api-1.0.md#5. One writer per request
C-MODULE-01
docs/modules/index.md#What you get by default, and what you declare
C-MODULE-02
docs/adr/0001-framework-distribution.md#5. Scaffold mechanics (sandloom new) docs/modules/index.md#What you get by default, and what you declare
C-MODULE-03
docs/adr/0001-framework-distribution.md#1. Consumption model — three tiers docs/adr/0001-framework-distribution.md#2. Package graph (monorepo + uv workspace, N wheels, independent versions) docs/adr/0001-framework-distribution.md#4. Assembly
C-MODULE-04
docs/adr/0002-framework-absorbs-module-boilerplate.md#Decision docs/adr/0002-framework-absorbs-module-boilerplate.md#Consequences docs/modules/index.md#What you get by default, and what you declare
C-MODULE-05
docs/reference/cli.md docs/modules/index.md#What a module may import
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005)
C-MSTORE-02
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005)
C-MSTORE-03
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005) tests/fixtures/specs/public-api-1.0-spec.md#6.3 Labels are derived from the mechanism
C-MSTORE-04
docs/adr/0010-public-api-1.0.md#6. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005) docs/infrastructure/index.md
C-OPS-01
docs/reference/cli.md docs/operate/operator-reference.md#Events: outbox relay and dead letters
C-OPS-02
docs/reference/cli.md docs/operate/operator-reference.md#Events: outbox relay and dead letters
C-OPS-03
docs/reference/cli.md docs/operate/operator-reference.md#Events: outbox relay and dead letters
tests/fixtures/specs/public-api-1.0-spec.md#5.6 Generated migrations with RLS
C-TEST-07
tests/fixtures/specs/public-api-1.0-spec.md#2.3 New names: exact signatures docs/adr/0009-route-policy-and-session-freshness.md#The route snapshot
C-TEST-08
tests/fixtures/specs/public-api-1.0-spec.md#2.3 New names: exact signatures
C-TEST-09
tests/fixtures/specs/public-api-1.0-spec.md#2.3 New names: exact signatures
C-TEST-10
tests/fixtures/specs/public-api-1.0-spec.md#2.3 New names: exact signatures docs/reference/testing.md
C-TEST-11
docs/reference/testing.md
C-TEST-12
docs/reference/testing.md
C-ROUTE-03
docs/adr/0009-route-policy-and-session-freshness.md#Checks (deterministic; each error names its fix) tests/fixtures/specs/public-api-1.0-spec.md#5.1 Model E and the module router