# ADR-0010: Public API 1.0 — one `sandloom.sdk` namespace, three API levels, derived declarations

- **Status:** Proposed (2026-10-25, revision 6 after a fifth independent review). Implementation starts after the owner approves the specification.
- **Date:** 2026-10-25
- **Deciders:** Sandloom maintainers and the owner
- **Specification:** [`tests/fixtures/specs/public-api-1.0-spec.md`](<https://github.com/Sandloom-dev/sandloom/blob/main/tests/fixtures/specs/public-api-1.0-spec.md>) (v6: exact signatures, the lifecycle matrix, checks, the generated capability table, the ledger migration, workstreams, both review dispositions)
- **Amends:** [ADR-0005](<https://sandloom.dev/docs/adr/0005-effect-model/index.md>) (guarantee levels become a lattice with derived labels), [ADR-0007](<https://sandloom.dev/docs/adr/0007-kernel-layers/index.md>) (an `sandloom.sdk` layer above the kernel), [ADR-0009](<https://sandloom.dev/docs/adr/0009-route-policy-and-session-freshness/index.md>) (Decision 3 wording; its planned sections become ledger ids).
- **Overriding rule (owner):** no guarantee weakens and no capability is removed; the guarantee ledger is the gate.
- **Research:** DX cognitive load (internal research, retained in the original private repository), DX competitor benchmark (internal research, retained in the original private repository), multi-store guarantees (internal research, retained in the original private repository), docs strategy (internal research, retained in the original private repository).

## Context

Sandloom delivers seven guarantees its competitors leave to the developer (benchmark §5), but a module author pays for them:

- **31 concepts** on the golden path (CRUD + event + cross-module call), against 18–20 for Encore, NestJS and Rails; 15 are plumbing or declarations the competitors derive.
- **315 public names** in 13 public modules; `import sandloom.core.di` exits without an environment, so `help()` does not work.
- **Agents read the kernel** (36 files in one product build) and edit 5–10 framework files per new module.
- **48 function-local imports** in the bundled modules (59 with examples), which agents copy.
- **Billing gates default to the user's account**, so an organization gate silently checks the wrong account.
- **A second writing transaction in a request is undetected**, and the guarantee levels are presented as a chain with unconditional labels.

ADR-0009 (accepted) removes route classes and makes route policy a default; its removals are implemented in this wave, not before it. This ADR decides the public API and the transaction rules the rest of the reduction needs.

## Decision

### 1\. One public namespace, `sandloom.sdk`, with a beginner tier

- Business modules and their tests import only public `sandloom.sdk` modules, generated contract stubs and contract DTOs, and their own package.
- Amendment ([ADR-0011](<https://sandloom.dev/docs/adr/0011-driver-api/index.md>)): a driver package is not a module. It imports the separate `sandloom.driverkit`, which modules may not import and the kernel never imports. Keeping it out of `sandloom.sdk` keeps this rule free of exceptions.
- Every existing public capability keeps a public home with its name and all its parameters. A table is generated by scanning today's public modules, every kernel import in the bundled modules and examples, and the ledger. The only exceptions are the names ADR-0009 removes, renames and same-semantics replacements, and the billing atoms' subject argument (Decision 7). Repository usage is never a reason to drop a public name. The scanner fails if any name has no public home.
- Each module declares `__all__` and `__beginner__`; 27 beginner names are taught by the generators, the tutorial and the agent cheat sheet. The surface is snapshotted.
- Importing `sandloom.sdk` reads no environment and performs no I/O: settings become a lazy proxy forced by every entry point, and an import audit removes the remaining import-time effects.
- No compatibility aliases.

### 2\. Three API levels; level 1 is built only from level 2

- L1 declarative (`define_module` discovery, `module_router`, typed events, `@on`, `@contract` / `@command`, `Contract(REF)`, typed saga steps, projections); L2 primitives (everything else public, including the hand-written `register(core) -> ModuleDescriptor` form); L3 kernel.
- L1 code imports only names that are public L2 primitives (import-linter contracts plus a positive test), and a test shows that L1 registration equals the hand-written L2 registration for every example module.
- Guarantees live in the kernel and apply to any router.

### 3\. Declarative module surface (router, typed events, typed contracts, consumers)

- **Router:** derived prefix and tags; authentication and tenant binding are injected per route, never on public or service routes; the tenant binding is for isolation only and keeps today's `400` for a missing tenant.
- **Events:** the envelope is unchanged (`id`, `channel`, `schema_version`, `trace_id`, `emitted_at`, `payload`); delivery metadata is not part of the payload; the producer or view role is derived from the channel prefix and may be stated; compatibility is checked against the committed snapshot before anything is written.
- **Contracts:** stateless provider classes keep every `contract_method` option (`idempotency_scope`, `planned`, `database`); callers use a generated typed reference (`Contract(X_Y_V1)`); `optional=True` returns `None` only when the provider is not deployed and is meant only where the business has a correct answer without it. Ambiguous unions keep working through codec 1.
- **Consumers:** `@on(E)` passes every `register_event_consumer` option.

### 4\. Transaction and replay lifecycle

- One normative matrix covers every route kind (read, write, sensitive, public, token issuer, webhook, streaming, operation intent, committing read) and every non-route unit (consumer, local and remote command, task, saga step).
- Every unit keeps today's commit semantics (commit on success with `before_commit` and `on_commit`, rollback on any exception); no queued effect is dropped.
- `db()` returns the session of the innermost unit; a closed unit's session refuses further use, cancellation rolls back, savepoints are shared. Cross-task ownership is a new footgun guard, not a guarantee: `db()` refuses a task that does not own the unit (child tasks open their own unit with `open_uow()`); passing a session object to another task is unsupported and detected on a best-effort basis (session and `sync_session` methods, cursor execution, attribute listeners only within the performance budget), with the undetectable forms listed.
- The commit-time re-check runs before the COMMIT of **every** unit of an armed request that commits a write or a queued effect, local and remote commands included (as today's `revocation_at_commit()`), with exact self-revocation exemptions. It is armed on every authenticated write, on a GET whose unit wrote, and by `at_commit()`; the automatic re-check skips units that commit nothing, while an explicit `at_commit()` checks every unit the request opens, empty ones included, as today.
- Billing's checkout and portal keep their protocol under a declared `operation_intent` mode: sequential short units; PSP calls inside `external_call()`; it and `release_check()` refuse to start while a unit or read-scope connection is open and, for their whole duration (the awaited authority re-read included), prevent any unit, read scope or billing connection checkout from starting in any task of the request, except the re-read's own read-contract calls (so no transaction spans the PSP call or the re-read); `release_check(account,   authority=)` requires the authority re-read for organization accounts. A 2xx must match one of two validated cases, or the response is discarded: **issue** (the check after the last external call, then exactly one unit with exactly one write statement) or **issued retry** (no external call, the check after the last committed unit, no write after it; the stored URL is returned, G-BILLING-05).
- Idempotency has three distinct modes: B (durable, body replay, never re-enters the handler), S (durable, status-only replay, never re-enters the handler) and X (in-flight reservation released at completion; a retry re-runs authorization and the handler, the effect's own record keeps it single). The automatic guard always uses S; a body is replayed only where the developer opts in with `replay_safe(reason=)` (recorded in the route snapshot) or an explicit guard, exactly as today; sensitive writes use S; issuers and intent routes require an explicit X guard; explicit guards keep all options.

### 5\. One writer per request

- A request-wide write admission refuses any write by a second unit before it reaches the cursor (`before_flush` and `before_cursor_execute` on every kernel engine, so ORM, Core, `text()` and `exec_driver_sql()` are all covered), including later, concurrent and nested units and unflushed ORM state; a statement counts as read-only only if it provably is (so `EXPLAIN ANALYZE`, `SELECT … INTO`, DML in CTEs, `COPY`, `CALL`, `DO` are writes); raw DBAPI handles are forbidden to modules; the same cursor hook enforces `db()` ownership. The guarantee's scope is stated: writes by database functions inside read statements and by other processes are out of scope.
- Records (idempotency, audit, outbox) live in the route's write store, inferred statically.
- `allow_multi_db_write` is the reviewed best-effort-1PC opt-out; intent routes admit sequential units.

### 6\. Guarantees as a lattice, labels derived from mechanisms (amends ADR-0005)

A guarantee is a delivery set over `{atomic, no_loss, no_dup}` (closed under `atomic ⇒ no_loss ∧ no_dup`) times a visibility; the meet is set intersection and minimum. Each effect's label is computed from its provider, its retention and retry windows, and its boundary: a command with the cache fallback has no `no_dup`; a consumer effect in another store gets `no_dup` only from a conditional write; consumer delivery is always qualified (`parks_on_poison_or_budget`, or `parks_on_explicit_poison` with `dead_letter=False`). Projections converge for full-state events with one owning source per field and a per-aggregate version; delta events are allowed only where the durable inbox makes them exactly once; tombstones are purged only behind a watermark that refuses older replays.

### 7\. Explicit authorization subjects

Tenant identity (the bound tenant, for isolation), tenant kind (personal, organization, canonical or unknown, from authoritative sources only) and the authorization subject (the billing account) are separate. Billing atoms take a required `of=`: `Account.user()`, `Account.org(param=...)` or `Account.bound_tenant()`, which resolves by tenant kind, is indeterminate for a canonical or unknown tenant, and ships only with its test table. No default can select another billing account. `Account.org()` keeps today's header and bound-tenant resolution, custom adapters and non-HTTP use. `Feature` and `WithinLimit` take `of=` too; a static rule refuses an organization route parameter combined with `Account.user()` without a reason; billing's own API chooses the account explicitly (no single-organization inference); a personal credit spend is checked against the signed subject.

### 8\. The application manifest; derived framework lists

The loader builds one manifest at boot and without I/O for the gate. Discovery imports fixed submodules, walks packages recursively, collects only objects the module owns, and applies registration atomically. Each module commits one generated snapshot. Framework lists are derived for app modules; the bundled modules keep hand-written expectations as independent oracles.

### 9\. Business modules carry no function-local imports

A new `sandloom check` rule forbids them in modules, examples and templates, except under `TYPE_CHECKING` and for a marked optional dependency.

### 10\. Checks and error codes

New refusals have stable codes (boot B008–B019, runtime R007–R013, static C021–C027, test T005–T009), a `fix:` line and an `sandloom explain` entry; B003, B004, B007 and C016 are retired. Laws are tested: typed signatures with mypy `--strict` and pyright; with Hypothesis the authorization algebra (unchanged, pinned against today's implementation for value, evaluation count and tier order), the lattice, the risk join, the wire codec (including unions), projections, sagas and write admission.

## Amendments to earlier ADRs

- **ADR-0005.** The level table is replaced by the lattice and derived labels (Decision 6); `replay-only` becomes a response axis.
- **ADR-0007.** `sandloom.sdk` sits above `sandloom.core`; the kernel never imports it; its layering is an import-linter contract.
- **ADR-0009.** Decision 3 reads "before the COMMIT of every unit of the request that commits a write or a queued effect, local and remote commands included; armed on every authenticated write, on a GET whose unit wrote, and by `at_commit()`; not automatic in operation-intent routes, which call `release_check()`". Remote commands under an armed re-check are propagated, replacing today's refusal (G-REVOKE-18). The planned sections map to the ids in specification §9.3.

## Owner decisions (2026-10-25)

The owner accepted the recommended answers to the 13 open questions of the first revision, with one overriding rule: no guarantee may weaken and no capability may be removed. Where an answer conflicted with the rule, the rule won.

| \# | Question | Decision |
| --- | --- | --- |
| 1 | One snapshot file per module | yes: `module.snapshot.json` |
| 2 | Organization subscription claims | organization gates are live calls in 1.0; a claim only if measurement shows a cost |
| 3 | Refuse ambiguous unions in contracts | **revised by the rule: not adopted.** Codec 1 preserves them, so refusal would remove a working capability. Unions keep working (codec 1 fallback, codec 2 when fully typed); SANDLOOM-C026 is an information-level hint |
| 4 | Kleene `And` | **revised by the rule (second and third reviews): not adopted.** Strong Kleene logic would allow `~(U & F)`, which today denies; complete evaluation under `Not` would break G-AUTHZ-02's short-circuiting. The algebra stays exactly as it is |
| 5 | Contract stub location | generated into the contract DTO package and committed |
| 6 | Router tenant default (and the organization inference built on it) | **revised by the rule:** the tenant-binding derivation is kept for isolation only; inferring the billing subject from it is dropped, because a tenant can be personal or the single-tenant canonical id and a default must never authorize against another billing account. Replaced by Decision 7 |
| 7 | One `Event` class with a derived role | yes; `role=` may state it explicitly |
| 8 | CLI unification | yes: `sandloom add`, `sandloom migrate` |
| 9 | pyright | for the framework's typing tests only |
| 10 | Lazy read units | lazy for reads, eager for writes |
| 11 | Carried multi-store questions | read-only secondary sessions; no Mongo store of record in 1.0; partition scope defined, `database` / `none` implemented; metric-only opt-out; consistency token capped at 16 keys; saga steps on a second database through a self-provided command contract |
| 12 | Non-relational projections in 1.0 | kernel API, proxy, kits and an in-memory driver; no production document driver |
| 13 | Eval budget | one counted run at the end, plus uncounted smoke runs |

The owner then decided the three questions the second revision raised (2026-10-25):

| \# | Question | Decision and rationale |
| --- | --- | --- |
| A | Billing-gate subject | Billing gates require `of=` (`Account.user()`, `Account.org(param=...)`, `Account.bound_tenant()`), no default, with the billing additions (static route/subject rule, explicit accounts in the billing API, the personal-spend check, `of=` on `Feature` / `WithinLimit`, `bound_tenant()` only with its test table, the helpdesk fix). `Account.org()` does not narrow header or bound-tenant resolution, custom adapters or non-HTTP use. *Rationale:* the only application gate in the repository checked the wrong account and its test pinned it; payment APIs take the customer explicitly |
| B | Replay when authorization runs in the handler | The owner decided that automatic replay returns a stored body only when authorization is route-level, otherwise status only; explicit guards keep all their options; sensitive, issuer and intent routes never replay a body. *Revised after the second review:* same caller and tenant does not prove continuing access to the resource. **Tightened after the fourth review:** "authorization is route-level" required a static proof of complete authorization, which the reviewer showed unsound (an ownership condition written as plain code has no call or raise). The automatic guard now always replays status only; a body is replayed only where the developer opts in with `replay_safe(reason=)` or an explicit guard, exactly as today. This is stricter than the decision and weakens nothing compared with the current code |
| C | Writes in GET units | A GET unit that actually writes arms the commit-time re-check automatically; `at_commit()` stays for explicit commit obligations; queued `on_commit` / `before_commit` effects are never dropped; existing writable GETs are not banned. *Revised after the second review:* v2's rollback of non-writing read units could drop queued effects, and its writing GETs committed unchecked |

## Note (2026-10-28): contract DTOs authored in the provider, generated into the DTO package

Owner decision of 2026-10-28, with `sandloom generate` (generation separated from testing: `sandloom test` never writes a generated file).

- **Change.** A provider declares its contract DTOs (pydantic models, Enums) in its own module, by convention its `schemas` module. `sandloom generate` writes a wire-shape copy of every DTO a provided contract signature names (nested ones included) into the shared DTO package (`<shared>/contracts/<module>_dto.py`, each class marked `@dto_copy`), and the stub names the copy. A `Contract(REF)` caller receives the copy's class on the local path and over RPC; the provider receives its own class. The bundled modules' hand-written DTOs moved into their modules; `sandloom.shared.schemas` is gone.
- **Why.** Hand-editing DTOs in the shared package split one module's code across two trees and made a module package (an ecosystem module, an ejected one) depend on a shared file it could not own. A module now carries its DTOs like its events, and every caller still imports one generated class.
- **SANDLOOM-C012's concern is preserved.** It existed because a caller process without the provider decoded the provider's class as a `dict` (local != remote). Callers never import the provider's class: they hold the generated copy, and both paths convert to it. What C012 refuses now is what no faithful copy can come from: a shape outside the closed set (the event set within the contract wire types, plus the copy's limits), another module's or a function-local class, a missing copy, a hand-edited or stale one.
- **Losslessness.** A copy carries fields, types, constraints, defaults, aliases, Literal / Enum values, nested models and the allowed `model_config` options; not methods or validators, which stay the provider's (they run where the provider binds an argument or builds a result). A property test holds the copy to accept exactly what the provider's class accepts, and both call paths to deliver equal values.
- **Ecosystem modules and `sandloom eject` keep their DTOs** in their own package; an application that composes an installed module generates the copies into its own shared package.

## Consequences

- **Golden path: 31 → 9 concepts** (10 with an authorization gate); an ordinary write route carries no policy line; a new module touches no file outside its package except generated contract stubs and event schemas.
- **Surface:** 27 beginner names; every capability keeps a public home and its parameters; `help()` works without an environment.
- **Guarantees:** none weakens. Stronger: authentication presence, write admission, compatibility checked before writes, derived labels, explicit billing subjects, enforced intent sequencing (closes G-BILLING-19's gap), and closed gaps G-UOW-06/07, G-EVENT-05, G-TENANT-12 delivery and G-KERNEL-11.
- **Costs:** a required `of=` on billing atoms; live organization gates; status-only automatic replay (a body needs an explicit opt-in); owner checks on session methods and attribute listeners; statement hooks for write admission; generated files in the repository; pyright and Hypothesis join the dev tools.
- **Acceptance:** the ledger green with no planned section left, a ledger migration test, a binding cachegrind gate (at most +1 % instructions per operation on the request and write rungs for this wave's in-process checks, unless the owner approves more; over-budget checks are dropped or moved off the hot path), independent behavioural oracles, `sandloom test --full`, the split conformance matrix, measured concurrent performance, and one agent eval (concepts ≤ 10, kernel reads 0, framework edits 0, footgun escapes 0).

## Alternatives considered

| Alternative | Why not |
| --- | --- |
| Keep today's public modules, beginner tier in docs only | does not fix import side effects or kernel reads |
| `sandloom.<theme>` instead of `sandloom.sdk.<theme>` | business modules occupy `sandloom.<name>` |
| Sugar that calls the kernel directly | could grant or weaken guarantees primitives cannot express |
| Derive an operation class from the HTTP method | model E has no classes |
| A commit-time detector of second writers | misses later and concurrent units and unflushed state; xid assignment is not a write signal |
| Infer the billing subject from the tenant binding | a tenant may be personal or canonical; it can select the wrong account |
| Refuse ambiguous contract unions | removes a capability codec 1 supports |
| Strong Kleene `And` | allows `~(U & F)`, which today denies |
| Complete evaluation under `Not` | adds live calls a short-circuit skips today (G-AUTHZ-02) |
| Once-per-request commit re-check | lets a later commit escape a revocation that landed after an earlier commit |
| Two-phase commit across stores | coordinator, in-doubt locks, coupled availability |
| A compatibility layer | this decision replaces the internal API that preceded the initial public alpha |

## Ledger mapping

This ADR's decisions are `planned` in `guarantees.yml` (`planned_adrs` names this wave). Specification §9 lists the 114 affected entries with their dispositions, the ids that replace ADR-0009's planned sections, the new entries and the removal records.

## Open questions

None pending: the owner decided A–C above. Implementation waits for the owner's approval of the specification.
