# AGENT.md — Basel bootstrap

This document is generated, never hand-written, and describes the whole Basel engine — not any tenant's data. It changes only when Basel itself changes. Basel's user and its developer are the same kind of thing: an agent. This document is what you read once, on arrival, to learn the system (design doc §1.2, §9.1).

- **Engine version:** 0.3.3
- **Metadata format version:** 1

## Profile boundary

Basel measures itself against six foundational principles (design doc §1.1): `GP-01`, `GP-02`, `GP-05`, `GP-06`, `GP-08`, and `GP-11` — an implementation that violates one is not an early version of the platform, it is a different product. M1 shipped as a deliberately non-conformant substrate; M2 and M4 closed most of the gap. Current status, stated plainly rather than implied:

| Principle | Status |
|---|---|
| `GP-01`, `GP-02` | Upheld. Metadata is the sole source of truth and the only way to change the system. |
| `GP-05` — models propose; deterministic systems commit | **Closed at M4** (design doc §12). Every write passes one pipeline: type validation, guard evaluation over a consistent snapshot, rollup recompute, invariant checks, then the write plus exactly one audit event in the same transaction. Functions on actions propose; only `commit()` commits. The boundary does not yet validate authorization or policy — those arrive with M6 authority. |
| `GP-08` — state carries history, evidence, epistemic status | **Closed at M4.** Every successful write records a transactional audit event; row history is queryable (`GET .../objects/{Object}/{id}/history`, see "Actions"). |
| `GP-11` — capabilities, not raw primitives | **Still violated.** Declared actions (plain actions and lifecycle transitions) exist as capabilities, but generic record mutation — `GP-11`'s named anti-example — remains the working surface. Closes once actions replace generic CRUD. |
| `GP-06` — intelligence distinct from authority | Vacuous until M6 — no authority model exists yet to separate from. |

Users, actors, authentication, authorization, and policy do not exist yet on the data plane — they are M6 authority work. Destructive metadata change is likewise deferred to M6 (see below).

### What is refused, not silently degraded

`PRINCIPLES.md:338` requires that constructs outside the supported profile be authoring-time errors, never runtime degradations, and Basel implements this literally (design doc §5.4). `lifecycles`, `actions`, `invariants`, `rollups`, `formulas`, and `guards` are implemented constructs — see their sections below. Vocabulary the parser recognizes but does not implement is **refused** at authoring time, along with any unknown key: `policies` and attribute-level `derived`; `computed:`/`derivations:`, refused by name at the real construct a tenant actually wants (`formulas:`/`rollups:` respectively — see "Formulas" and "Aggregates, rollups, and invariants"), not a still-reserved family; relationship ownership vocabulary (M6, see "Relationships"); and an action's `body:`/`grant:` — refused naming `function: <name>`/`scope:` (ADR-0064; see "Functions on actions" below). (M4.5 shipped the rung-2 `create:`/`update:`/`link:` effect forms this list used to reserve, plus a new `unlink:` verb — see "Nested create and update" and "Actions" below. Task 3 (DR-05) landed `default:` as real attribute vocabulary, and Task 4 (DR-06) landed the independent `backfill:` beside it — see "Metadata grammar and type table" below — leaving `derived` the only attribute-level key still refused.)

```
refused: Widget.policies
  policies are recognized but not implemented in M1
```

**Destructive change is refused, not performed (design doc §5.3).** Metadata apply is additive-only until M6's declared-action machinery: `DROP COLUMN`/`DROP TABLE`, `DROP CONSTRAINT`, any retype, any rename, and removing an enum value are all refused. Removing a declared element from a document does not silently drop it — it fails:

```
refused: Widget.legacy_code
  declared in version 6, absent in submitted document
  removing elements is not supported in M1; declare intent in M6
```

`ADD COLUMN`, `CREATE TABLE`, `CREATE INDEX`, adding an enum value, and `ADD CONSTRAINT` (flagged, since it can fail against existing rows) are all allowed. Tenant deletion is the one destructive operation the engine permits, and it is an explicit control-plane call with its own confirmation, never something a metadata apply can reach.

## Endpoints

Bodies are JSON unless the table says otherwise; Git smart-HTTP routes use Git protocol bytes and this document is `text/markdown`. `{key}` is a tenant key; `{Object}`/`{attr}` are authored metadata names. Every `/tenants/{key}/...`, `/accounts/...`, and `/control/...` route authenticates except the four the next section names — read "Authentication" before calling anything below `GET /health`.

| Method | Path | Body | Success | Notes |
|---|---|---|---|---|
| GET | `/` | — | 200 `text/markdown` | this document |
| GET | `/health` | — | 200 json / 503 | liveness + engine version; `503 superseded` once another instance has claimed the singleton epoch — this instance is shutting down |
| GET | `/abi` | — | 200 json | the served function ABI package versions, each linking its WIT and contract schema (see "Functions") |
| GET | `/abi/basel-function/{version}/function.wit` | — | 200 `text/plain` / 404 | the normative WIT for that package version, content ETag; `404 unknown_abi_version` outside the served set |
| GET | `/abi/basel-function/{version}/contract.schema.json` | — | 200 json / 404 | JSON Schema of the `basel-contract` custom section for that package version |
| POST | `/accounts/sessions` | `{"login", "password"}` or `{"token"}`, `"profile": "ui"`? | 201 json / 401 / 429 | the account session exchange (see "Authentication") — one of the four endpoints that is itself unauthenticated; `profile: "ui"` answers with cookies instead of a token |
| DELETE | `/accounts/sessions/current` | — | 204 | revoke the session this request authenticated with; a `ui` session's two cookies are cleared |
| POST | `/accounts/dev/sessions` | `{"login"}`, `"audience": "ui"|"api"|"control"`?, `"profile": "ui"`? | 201 json / 404 | **development only** — open a real session for a login with no password and no token (see "Development mode"); answers in `POST /accounts/sessions`' own two shapes |
| GET | `/accounts/me` | — | 200 json | the authenticated account, minus every secret; carries `memberships_href` |
| GET | `/accounts/me/memberships` | — | 200 json | every tenant this ONE session reaches, oldest tenant key first — the endpoint that resolves which `/tenants/{key}/...` paths the token opens |
| GET | `/accounts/me/credentials` | — | 200 json | the caller's own live token credentials; never a secret |
| POST | `/accounts/me/credentials` | `{"audience", "tenant_scope", "label", "expires_at"?}` | 201 json | mint a bearer credential for the caller's own account; the plaintext token is returned once |
| DELETE | `/accounts/me/credentials/{id}` | — | 204 / 404 | revoke one of the caller's own credentials and every live session it authenticated |
| POST | `/accounts/me/credentials/{id}/rotate` | — | 201 json / 404 | replace one of the caller's own credentials with a successor carrying its terms; the predecessor and every session it authenticated are revoked in the same transaction |
| POST | `/accounts/me/agents` | `{"login", "display_name"}` | 201 json / 403 / 409 / 422 / 429 | mint an agent account owned by the caller; only a `human` account under direct live authority may create one; rate-limited per account and per address |
| GET | `/accounts/me/agents` | — | 200 json | the agent accounts the caller owns; never a secret |
| POST | `/accounts/me/password` | `{"current", "new"}` | 204 | change the caller's own password; every other session of this account is closed |
| POST | `/accounts/password-resets/complete` | `{"secret": "bse_...", "password": "..."}` | 201 json / 400 | redeem a one-time password-reset secret into a new password; unauthenticated, because the secret is the authentication |
| POST | `/accounts/invitations/accept` | `{"secret"}` (with a credential) or `{"secret", "login", "password", "display_name"}` (without one) | 201 json / 400 / 401 / 409 / 422 | accept a tenant invitation — the caller's own account joins, or a new one is created and joined, in one transaction; the secret rides in the BODY, never the path |
| GET | `/tenants` | — | 200 json | excludes tombstoned by default |
| DELETE | `/tenants/{key}` | `{"confirm": "<key>"}` | 204 | deletes the tenant, its history and the service accounts it manages; other accounts keep only their identity; the key is free again; nothing destroys data on a verb alone |
| POST | `/control/sessions` | `{"login", "password"}` | 201 json / 401 / 429 | open a `bcs_` control session — unauthenticated, since it is where control authentication comes from |
| DELETE | `/control/sessions/current` | — | 204 | revoke the control session this request authenticated with |
| GET | `/control/whoami` | — | 200 json | the authenticated control caller, minus every secret |
| POST | `/control/accounts` | `{"kind", "login", "display_name", "email"?}` | 201 json | create a new account; holds no instance role and belongs to no tenant |
| GET | `/control/accounts?q=&kind=&active=&limit=&after=` | — | 200 json | every account on the instance, oldest first, one page at a time; `q` matches login, email and display name |
| GET | `/control/accounts/{id}` | — | 200 json / 404 | one account with its lifecycle stamps and every active membership |
| DELETE | `/control/accounts/{id}` | — | 204 / 403 / 404 / 422 | delete an account outright with its credentials, sessions and audit rows; refused for the caller, an instance-role holder, a service account (it goes with its tenant), an owner of other accounts, or an active member of any tenant |
| POST | `/control/accounts/{id}/instance-roles` | `{"role": "operator", "held": true|false}` | 204 / 400 / 403 / 404 / 422 | grant or revoke the `operator` instance role; revoking also ends the account's live control sessions and credentials |
| POST | `/control/accounts/{id}/active` | `{"active": true|false}` | 204 / 400 / 403 / 404 / 409 / 422 | activate or deactivate an account instance-wide; deactivation ends every membership, grant, and session it holds in one transaction |
| POST | `/control/accounts/{id}/password-resets` | — | 201 json / 403 / 404 | a one-time `bse_` password-reset secret for the account; this is the only response that ever carries it |
| POST | `/control/tenants` | `{"key", "admin": {"login"} | {"email"}}` | 201 json / 404 | provision a tenant and mint its first admin's invitation in the same transaction; the `bsi_` secret is in this response and nowhere else, ever again; an `admin.login` naming no live account is `404 invitation.login` and provisions nothing |
| POST | `/control/elevations` | `{"tenant", "tenant_principal", "reason", "ttl_seconds"}` | 201 json / 404 | borrow a tenant identity, audibly: answers with a TENANT `api` session bound to the elevation row, capped at one hour |
| DELETE | `/control/elevations/{id}` | — | 204 / 404 | hand the borrowed authority back; the tenant session it opened stops resolving on its next request |
| GET | `/control/elevations?live=&limit=&after=` | — | 200 json / 400 | elevations newest first, one page at a time, with reason, identities and whether each is still live; never the session it opened; `live=false` (the default) is EVERY elevation, live or not |
| GET | `/control/attempts?actor=&action_name=&code=&limit=` | — | 200 json | the instance attempt sink: every refusal belonging to no tenant, plus every one naming a tenant key that resolves to nothing |
| GET | `/control/attempts/export?from=&to=&after_id=&limit=` | — | 200 `application/x-ndjson` | the instance attempt sink over a ≤31-day window, oldest first, one object per line — each naming the tenant key it was refused under — and a final `{"export":{rows, complete[, next_from, next_after_id]}}` line; no session, credential, trace, reason or action name; each export is itself a `control:attempt.export` event; operators only |
| GET | `/tenants/{key}/whoami` | — | 200 json | the authenticated caller as the engine sees them, minus every secret |
| GET | `/tenants/{key}/invitations` | — | 200 json | every invitation into this tenant that can still be redeemed, newest first; never a secret |
| POST | `/tenants/{key}/invitations` | `{"login"|"email": "...", "kind": "human"|"agent", "roles": [...]}` | 201 json / 403 / 404 / 409 / 422 | invite an existing account (`login`) or an email that is not yet one (`email`) into the tenant; the `bsi_` secret is in this response and nowhere else, ever again |
| DELETE | `/tenants/{key}/invitations/{id}` | — | 204 / 404 | withdraw an invitation, so its secret stops redeeming from the next request on |
| GET | `/tenants/{key}/principals` | — | 200 json | every principal in the tenant this caller may read, oldest first, each carrying exactly the attributes their readable fields admit; carries no secret |
| DELETE | `/tenants/{key}/principals/{id}` | — | 204 / 404 / 422 | remove a membership — deactivated, not deleted, so the tenant's own history stays readable; the account and its memberships of other tenants are untouched; `422 last_admin` rather than leave the tenant with no ACTIVE admin |
| GET | `/tenants/{key}/principals/{id}/roles` | — | 200 json / 403 / 404 | the role ids this principal holds a membership row for. Your own is always readable; anyone else's needs `role.assign` |
| PUT | `/tenants/{key}/principals/{id}/roles/{role}` | — | 204 / 403 / 404 / 422 | assign a role id; needs `role.assign`, plus `role.assign_admin` for `role_admin`; `422 unknown_role` for an id this metadata version does not declare, and `400 validation_error` for `role_authenticated`, the built-in virtual role nobody holds a row for. Idempotent |
| DELETE | `/tenants/{key}/principals/{id}/roles/{role}` | — | 204 / 403 / 404 / 422 | unassign a role id, idempotently; `422 last_admin` rather than leave the tenant with no ACTIVE admin, and `400 validation_error` for `role_authenticated`, which is virtual and cannot be taken away |
| PUT | `/tenants/{key}/principals/{id}/manager` | `{"manager": UUID|null}` | 200 json / 403 / 404 / 422 | assign or clear a visible Principal manager under direct live principal.manage; atomically update reports_descendants, reject cycles and depth above eight, and audit the change |
| POST | `/tenants/{key}/dev/members` | `{"login", "display_name"?, "roles"}` | 201 / 200 json / 400 / 404 / 422 | **development only** — create (or reuse) a credential-less account and its membership holding `roles` (see "Development mode"); `201` on creation, `200` on reuse |
| POST | `/tenants/{key}/principals/{id}/active` | `{"active": true|false}` | 200 json / 403 / 404 | activate or deactivate under direct `principal.manage` authority and target visibility. Deactivation atomically revokes sessions and grants; return read-projected Principal or only id on visibility loss |
| POST | `/tenants/{key}/installations` | `{"agent_login", "roles"}` | 201 json / 403 / 404 / 409 / 422 | host an agent the caller already owns (`status: "installed"`), or invite its owner's consent (`status: "invited"`, with the invitation's `bsi_` secret) |
| POST | `/tenants/{key}/services` | `{"login", "display_name", "roles"}` | 201 json / 403 / 409 / 422 | create a service account and its membership in one transaction; a service has no self-service credentials |
| POST | `/tenants/{key}/services/{id}/credentials` | `{"audience": "api"|"git", "label", "expires_at"?}` | 201 json / 403 / 404 / 422 | mint a bearer credential for a service, scoped to this tenant only; the plaintext token is returned once |
| DELETE | `/tenants/{key}/services/{id}/credentials/{credential}` | — | 204 / 404 | revoke one of a service's credentials and every live session it authenticated, in one transaction |
| POST | `/tenants/{key}/objects/{object}/{id}/shares` | `{"grantee": UUID, "actions": [...], "reason": text, "expires_at": optional RFC3339}` | 201 json / 403 / 404 / 422 | transfer current direct actions on a sharing-enabled visible record; no re-sharing. Update fields are privately frozen from transferable permits and the recipient readable fields; receipts omit reason and field evidence |
| GET | `/tenants/{key}/shares` | `?after`, `?limit` | 200 json / 403 / 422 | one page of the live shares this tenant holds, oldest first, under `share.manage`; `limit` defaults to 100 and caps at 500, `next` is the cursor to hand back as `after` (`null` on the last page) and `count` is this page's rows; rows carry the creation receipt's own fields and never the reason, the frozen update fields or the provenance |
| DELETE | `/tenants/{key}/shares/{id}` | — | 204 / 403 / 404 | revoke as the grantor or under direct share.manage; idempotent and atomically audited |
| GET | `/tenants/{key}/grants` | — | 200 json | the delegation grants the caller is a party to, as delegator or delegate, newest first |
| POST | `/tenants/{key}/grants` | `{"delegate", "audience": "api"|"git", "expires_at", "reason", "scope"?}` | 201 json / 403 / 422 | delegate to an agent/service until `expires_at`, which may not outlast the caller's own session; `reason` is mandatory |
| DELETE | `/tenants/{key}/grants/{id}` | — | 204 / 404 | revoke a grant and every session it opened; either party may. A grant you are not party to is `404`, never `403` |
| POST | `/tenants/{key}/grants/{id}/sessions` | — | 201 json / 404 / 422 | open the delegated session: a `bss_` token for the delegate, resolving with the delegator as effective principal |
| GET | `/tenants/{key}/attempts?actor=&action_name=&code=&limit=` | — | 200 json | this tenant's attempt log — every refusal, newest first, with no free text |
| GET | `/tenants/{key}/audit/export?from=&to=&after_id=&limit=` | — | 200 `application/x-ndjson` | the ADR-0022 public projection of this tenant's audit over a ≤31-day window, oldest first, one object per line and a final `{"export":{rows, complete[, next_from, next_after_id]}}` line; no session, credential, trace, reason or parameters; each export is itself a `security:audit.export` event; needs `audit.export` |
| GET | `/tenants/{key}/attempts/export?from=&to=&after_id=&limit=` | — | 200 `application/x-ndjson` | this tenant's refusals over a ≤31-day window, oldest first, one object per line and a final `{"export":{rows, complete[, next_from, next_after_id]}}` line; no session, credential, trace, reason or action name; each export is itself a `security:attempt.export` event; needs `security_attempt.export` |
| GET | `/tenants/{key}/git/status` | — | 200 json | canonical `main` oid and metadata-version linkage |
| GET | `/tenants/{key}/git/receives` | — | 200 json | structured receive audit, diagnostics, and phase timings |
| GET | `/git/{key}/info/refs?service=git-upload-pack` | — | 200 Git protocol | smart-HTTP clone/fetch discovery; use `git-receive-pack` for push |
| POST | `/git/{key}/git-upload-pack` | Git protocol bytes | 200 Git protocol | clone/fetch from canonical objects |
| POST | `/git/{key}/git-receive-pack` | Git protocol bytes | 200 Git protocol | authenticated guarded push to protected `main` |
| POST | `/git/{key}/plan` | exact commit/object JSON | 200 json | authenticated preflight; quarantines but never advances `main`; the response's `"advisories"` array is always present (empty when there's nothing to say) — `guard_is_state_predicate` (`DR-13`, see "Guards") and `backfill_unused` (`DR-06`, see "Metadata grammar and type table") today's only codes — and never blocks this plan or a later apply |
| GET | `/tenants/{key}/reflect` | — | 200 json | all reflected metadata for this tenant |
| GET | `/tenants/{key}/reflect/authority` | — | 200 json / 404 | this tenant's declared authority: roles, permits, readable fields, and assertions (see "Policy") |
| GET | `/tenants/{key}/authz/declared_grant_matrix` | — | 200 json / 403 / 409 | metadata.read: declared role × object × action cells and complete permit conditions; not effective permissions |
| POST | `/tenants/{key}/authz/explain` | `{action, record\|object, version?, patch?, fields?, target?, principal?}` | 200 json | why this actor may or may not do this — the decision and its determining permits for any member; conditions, outcomes, readable fields and the row filter under `authz.explain_full`; another principal or a hidden record under `authz.explain_others`; never writes, and `Cache-Control: no-store` |
| GET | `/tenants/{key}/authz/subjects?action=&object=&record=&after=&limit=` | — | 200 json | who holds `action` on `object` (or on `record`): the exact principals the permits and live shares admit, paged, under the caller's `Principal` readable fields, or `unsupported` naming the permit it cannot reverse; needs `authz.lookup_subjects` |
| GET | `/tenants/{key}/reflect/objects` | — | 200 json | every declared object, summarized |
| GET | `/tenants/{key}/reflect/objects/{Object}` | — | 200 json / 404 | one object: attributes, relationships |
| GET | `/tenants/{key}/reflect/objects/{Object}/attributes/{attr}` | — | 200 json / 404 | one attribute, including its queryable operators |
| GET | `/tenants/{key}/reflect/objects/{Object}/guards` | — | 200 json / 404 | this object's declared guards, verbatim (see "Guards") |
| GET | `/tenants/{key}/reflect/packages` | — | 200 json | installed packages: version, dependencies, provenance and what each contributes, by kind (see "Full names") |
| GET | `/tenants/{key}/reflect/relationships` | — | 200 json | every declared relationship, summarized |
| GET | `/tenants/{key}/reflect/relationships/{Name}` | — | 200 json / 404 | one relationship: endpoints, traversal names, properties (see "Relationships") |
| GET | `/tenants/{key}/reflect/relationships/{Name}/guards` | — | 200 json / 404 | this relationship's declared guards, verbatim (see "Guards") |
| GET | `/tenants/{key}/ui` | — | 200 json / 304 / 404 / 501 | evaluated UI identity, counts, locales, theme, and links |
| GET | `/tenants/{key}/ui/app` | — | 200 json / 304 / 404 / 501 | evaluated app shell: `app`, `title`, `route`, the installed `apps`, navigation, home, theme, presentation `selection`, and root tree |
| GET | `/tenants/{key}/ui/objects` | — | 200 json / 304 / 404 / 501 | localized object-surface listing |
| GET | `/tenants/{key}/ui/objects/{Object}?mode=list|view|create|edit&locale=...` | — | 200 json / 304 / 404 / 422 / 501 | one normalized object surface |
| GET | `/tenants/{key}/ui/pages` | — | 200 json / 304 / 404 / 501 | localized custom-page listing, including pages placed on an object (`placed`) |
| GET | `/tenants/{key}/ui/pages/{page}?locale=...` | — | 200 json / 304 / 404 / 501 | one normalized custom page |
| GET | `/tenants/{key}/ui/components` | — | 200 json / 304 / 404 | installed component catalog |
| GET | `/tenants/{key}/ui/components/{component}@{major}` | — | 200 json / 304 / 404 | complete component manifest and examples |
| POST | `/tenants/{key}/ui/queries/{query_id}` | json + If-Match | 200 json / 404 / 412 / 422 / 428 / 501 | execute an apply-compiled named UI query |
| GET | `/tenants/{key}/ui/assets/{logical_name}/{sha256}` | — | 200 asset bytes / 304 / 404 | content-addressed tenant asset, exact bytes; strong digest ETag, `public, max-age=31536000, immutable`; a mismatched logical name/sha256 pair is 404 |
| POST | `/tenants/{key}/ui/artifacts` | raw bytes + `Content-Type` `text/javascript` or `text/css` | 201 json / 200 json / 403 / 413 / 415 / 422 | upload one immutable UI implementation artifact (ADR-0050): `{sha256, kind, media_type, bytes, url, created}`; idempotent on the same bytes (200, `created: false`); 4 MiB cap; the same signature check as `ui/assets/`; needs `ui.release` |
| GET | `/tenants/{key}/ui/artifacts/{sha256}` | — | 200 artifact bytes / 304 / 404 | content-addressed artifact, exact bytes; strong digest ETag, `public, max-age=31536000, immutable`, `nosniff` |
| GET | `/tenants/{key}/ui/activations` | — | 200 json / 404 | the tenant's UI activation state: `activation_revision` (also the ETag, ready for `If-Match`), `selections` per `<id>@<major>` with `effective`, and the append-only `history` |
| POST | `/tenants/{key}/ui/activations` | json + If-Match | 201 json / 403 / 404 / 412 / 422 / 428 | activate `{component, entry: sha256|null, styles: [sha256], reason[, rollback_of]}` at `If-Match: "<activation_revision>"` (compare-and-set: 412 `stale_activation` names the current revision; 428 without the header); 422 `unknown_component`, `unknown_artifact`, `artifact_kind_mismatch`, `invalid_reason`, `unknown_revision`; needs `ui.release`; each activation is one `ui:release` audit event. Presentation releases use `{selections: [{component, entry, styles, declaration}], reason, expected_ui_checksum, presentation_digest, rollback_of?}`; declaration is the component endpoint's `release_contract`; the complete batch shares one revision |
| POST | `/tenants/{key}/ui/frames/{component}@{major}` | Origin header | 201 json / 400 / 404 / 409 | ADR-0051: mint a frame for an `execution: isolated` component — `frame.url` on the isolated origin with a sealed two-minute ticket; 409 `isolation_unavailable` when no isolated origin is established or it is the caller's own |
| GET | `/tenants/{key}/ui/frames/{component}@{major}` | ?ticket= | 200 html / 404 / 412 | the frame endpoint: no session read, the ticket is the authorization; the isolated page's document under its CSP (`sandbox allow-scripts`, `connect-src 'none'`); 412 `stale_ui` for a ticket sealed under a superseded metadata version |
| GET | `/tenants/{key}/ui/frames/{component}@{major}/assets/{logical_name}/{sha256}` | — | 200 asset bytes / 304 / 404 | the frame asset endpoint: no session, `Access-Control-Allow-Origin: *`; only that component's entry and stylesheets and the tenant's image/font assets, by digest |
| GET | `/tenants/{key}/reflect/ui?app=...` | — | 200 json / 404 | one app's UI source ownership and provenance, topology, query contracts, and evaluated links |
| GET | `/tenants/{key}/reflect/ui/objects/{Object}?app=...` | — | 200 json / 404 | one object view's source and provenance, modes, overlays, queries, and evaluated links |
| GET | `/tenants/{key}/reflect/ui/pages/{page}?app=...` | — | 200 json / 404 | one page's source and provenance, route or placement, queries, and evaluated link |
| GET | `/tenants/{key}/reflect/lifecycles` | — | 200 json | every declared lifecycle, flattened and navigable (see "Actions") |
| GET | `/tenants/{key}/reflect/actions` | — | 200 json | every declared plain action, objects AND relationships (see "Actions") |
| GET | `/tenants/{key}/reflect/invariants` | — | 200 json | every declared invariant, flattened and navigable (see "Aggregates, rollups, and invariants") |
| GET | `/tenants/{key}/reflect/rollups` | — | 200 json | stable rollup topology with metadata-version ETag and diagnostics link |
| GET | `/tenants/{key}/reflect/formulas` | — | 200 json | every declared formula, flattened and navigable, metadata-version ETag (see "Formulas") |
| GET | `/tenants/{key}/reflect/validators` | — | 200 json | every declared admission validator, flattened and navigable, metadata-version ETag (see "Functions") |
| GET | `/tenants/{key}/reflect/functions` | — | 200 json | every declared function (with its role), every `wasm/` binary and its referencing bindings, and every attachment of a function to an action or transition, metadata-version ETag (see "Functions") |
| GET | `/tenants/{key}/reflect/types` | — | 200 json | every declared named scalar type (`types/`), flattened and navigable (see "Metadata grammar and type table") |
| GET | `/tenants/{key}/reflect/types/{name}` | — | 200 json / 404 | one named scalar type's own `base`/`check` (see "Metadata grammar and type table") |
| POST | `/tenants/{key}/queries/{name}` | json | 200 json / 404 / 412 / 422 / 501 | execute a declared application query (`queries/`) by its authored name; If-Match is optional here, unlike the UI endpoint above (see "Metadata grammar and type table") |
| GET | `/tenants/{key}/reflect/queries` | — | 200 json | every declared application query (`queries/`), flattened and navigable (see "Metadata grammar and type table") |
| GET | `/tenants/{key}/reflect/queries/{name}` | — | 200 json / 404 | one application query's own contract (see "Metadata grammar and type table") |
| POST | `/tenants/{key}/functions/{name}` | `{"arguments": {...}, "execution": {"seed": "..."}}` | 200 json / 404 / 422 | invoke one pure function; no audit event (see "Functions") |
| GET | `/tenants/{key}/diagnostics/rollups?limit_events=N` | — | 200 json / 400 | bounded audit-window measurements; no-store and no metadata ETag |
| GET | `/tenants/{key}/diagnostics/invariants/objects/{Object}/{name}/violations?limit=N&cursor=ID` | — | 200 json / 400 / 404 | paginated current FALSE/NULL records on an object invariant; read-only and no-store |
| GET | `/tenants/{key}/diagnostics/invariants/relationships/{Relationship}/{name}/violations?limit=N&cursor=ID` | — | 200 json / 400 / 404 | paginated current FALSE/NULL links on a relationship invariant; read-only and no-store |
| GET | `/tenants/{key}/metadata` | — | 200 verbatim / 404 | current metadata document, verbatim |
| GET | `/tenants/{key}/metadata/versions` | — | 200 json | metadata history |
| GET | `/tenants/{key}/metadata/versions/{n}` | — | 200 verbatim / 404 | one historical document, verbatim |
| POST | `/tenants/{key}/query` | `{"bql": "..."}` | 200 json | BQL in, rows out |
| POST | `/tenants/{key}/objects/{Object}` | `{field: value, ...}` | 201 json + ETag | create one row; ETag is the new opaque evt_ row version |
| GET | `/tenants/{key}/objects/{Object}/{id}` | — | 200 json + ETag / 404 | read one row by id and version — TypeID or bare uuid (see "Ids: TypeID on the wire") |
| PATCH | `/tenants/{key}/objects/{Object}/{id}` | `If-Match: "evt_..."` + `{field: value, ...}` | 200 json + ETag / 404 / 412 / 428 | version-guarded partial update |
| DELETE | `/tenants/{key}/objects/{Object}/{id}` | `If-Match: "evt_..."` | 204 / 404 / 412 / 428 | version-guarded delete |
| POST | `/tenants/{key}/objects/{Object}/{id}/convert` | `If-Match: "evt_..."` + `{"$type": "<Kind>", "fields": {...}, "reason"?: "..."}` | 200 json + ETag / 403 / 404 / 412 / 422 / 428 | version-guarded **conversion**: moves the row to another concrete kind of its family, keeping its id, its prefix and its history. `fields` supplies the destination's own members only — a member both kinds share is retained, and editing one is a PATCH; `reason` is the usual audit envelope field, at the body's top level beside `$type`. Checked as a delete of the old kind AND a create of the new one, so both permits are required |
| GET | `/tenants/{key}/objects/{Object}/{id}/history` | — | 200 json / 404 / 422 | audit history, newest first (see "Actions") |
| POST | `/tenants/{key}/objects/{Object}/{id}/actions/{name}` | `If-Match: "evt_..."` + `{<declared param>: value, ..., "reason"?: "..."}` | 200 json + ETag / 404 / 409 / 412 / 422 / 428 | version-guarded lifecycle transition or plain action (see "Actions"); `?measure=true` runs it under measurement ceilings and rolls back, answering `{measured, outcome, failure?, row?, invocations}` — the cost of every function it ran beside its budgets, a refusal included |
| GET | `/tenants/{key}/invocations?element=&record=&limit=` | — | 200 json / 404 / 422 | the invocation log, newest first: what each function invocation consumed (fuel, rows, host calls, wall, operations) beside the budget it ran under, for committed, refused, and measured invocations alike |
| POST | `/tenants/{key}/proposals` | root, operations, reason? | 200 json / 403 / 404 / 412 / 422 / 428 | atomic imports and administration; proposal.submit plus every operation witness; existing root requires If-Match (BSL-REQ-209) |

**Control mutations (BSL-REQ-211):** Instance operators retain authority through live control sessions, and an operator is an account holding the `operator` instance role. Creating an account, instance-role and activation changes, password resets, elevation/revocation and tenant lifecycle writes recheck that authority inside their own transaction — against the LOCKED account row, not a context snapshot — and record sanitized instance security evidence atomically. A managed password reset replaces the account's old password and reset credentials and its held sessions; it requires an active human or agent account, and refuses a `service`, which is keyed only by the tenant that manages it. Revoked or expired source credentials invalidate control sessions, and so does losing the `operator` role. Provisioning commits the tenant, its engine principal, the first administrator's invitation and the security event together — no admin account is created, because a tenant's first admin is invited exactly as its second one is. Deactivating an account deactivates every membership it holds in one transaction across every tenant, and refuses `last_admin` in any of them. The last active operator cannot lose the role (`last_operator`). Elevations require a nonempty reason (at most 500 characters) and positive TTL capped at one hour; their tenant session expires no later than the elevation and reaches only that one tenant. Control logout is idempotent.

**Raw proposals (BSL-REQ-209):** POST a closed object with `root: {object, id?}`, `operations` (1–128), and optional `reason`. Record operations are `{op: create, object, local_id?, values}`, `{op: update, object, target, values}`, and `{op: delete, object, target}`. Links are `{op: link, relationship, from, to, properties?, local_id?, source?}`; unlinks are `{op: unlink, relationship, link, source}`. A source is `{target, side: from|to}`; omission on link selects its from endpoint. A link address is `{kind: id, id}` or `{kind: pair, from, to}` (exactly one instance required). Targets accept the declared object's TypeID or UUID, or an earlier `$local` alias. Record and link aliases have separate namespaces; ordinary ref-valued fields require concrete IDs. Unknown keys and internal operations are refused. The root is the first record target or declaring relationship source. Existing roots require a strong `If-Match`; a new root omits id and must be the first create and survive. The precondition asserts only the root version. Every operation still needs its own current authority. Results contain `event_id`, projected `records`, identity-only `links`, and surviving typed `local_records`/`local_links` maps; one failure rolls back the whole batch.

**Status code vocabulary:** `200`/`201`/`204` success; `304` an evaluated `/ui` metadata request whose `If-None-Match` matches its locale-and-surface ETag (empty body, `ETag` still set); `400` malformed request body or a BQL parse/compile error; `404` unknown tenant, object, attribute, or row; `409` a concurrent-change conflict (a stale Git base, candidate, or approval, or two pushes racing) or a wrong-from action invocation (the record isn't currently in one of the invoked transition's declared `from` states, see "Actions"); `422` a validation failure (e.g. `validation_error`, or `invalid_bind` for a value that doesn't match its attribute's type), a refused plan, or a refused guard (`guard_failed`, see "Error shape"); `401` an authentication refusal (uniformly `unauthenticated`, or the self-naming `audience_mismatch` — see "Authentication"); `403 forbidden` an authenticated caller refused an operation; `415 unsupported_media_type` a JSON endpoint handed a body that did not declare `application/json`; `429 rate_limited` the authentication flood gates; `500` an internal error, logged server-side and never leaked into the response body; `503 superseded` — `/health` only — another instance has claimed the singleton epoch; it is now the live one, and this one is shutting down.

## Authentication

Every `/tenants/{key}/...` route authenticates, and so does every `/accounts/...` and `/control/...` route. Four endpoints do not, because each one is where authentication comes from: `POST /accounts/sessions`, `POST /accounts/password-resets/complete`, `POST /accounts/invitations/accept`, and `POST /control/sessions`. A dev-mode server adds two more, for the same reason — see "Development mode"; they are absent from every other instance. `GET /`, `/health`, and `/abi/...` are open — they describe the engine, not a tenant. The two tenant-*management* endpoints (`GET /tenants`, `DELETE /tenants/{key}`) take a **control** session rather than a tenant one: a tenant's own administrator does not get to create or delete tenants.

**Authenticating is not being authorized.** Tenant policies govern actor-facing reads and the implemented write paths described under Policy below. Existing-record mutation targets are checked through the caller's read filter; hidden targets answer `404`. An admitted action may compute and act on internal records beyond its caller's read scope, while its result and failure channels obey public projection rules. Authentication alone grants no operation.

### The exchange

`POST /accounts/sessions` trades a credential for a session, and it is the only exchange there is. Identity is an ACCOUNT in the control plane rather than a login inside one tenant, so the per-tenant `POST /tenants/{key}/sessions` is gone. The body is either `{"login": "...", "password": "..."}` or `{"token": "<bst_ or bsg_ credential token>"}`, optionally with `"profile": "ui"`. Content type must be `application/json` (`415 unsupported_media_type` otherwise, on this and every other JSON endpoint).

Without a profile the answer is `201 {"token": "bss_...", "expires_at": "...", "account": {...}}` and the token is yours to send as `Authorization: Bearer <token>`. This is the agent and CLI path. A session token is **not** the credential token: the exchange takes the long-lived credential, the session is what every other endpoint accepts, and `POST /accounts/sessions` is the only place the two meet.

**One session reaches every tenant the account belongs to.** The session is the account's; a request to `/tenants/{key}/...` resolves it to that account's MEMBERSHIP in that tenant, which is a different principal id in each and is checked under that tenant's own roles. `GET /accounts/me/memberships` names them. Two things narrow the reach: the credential behind the session carries a `tenant_scope` (one tenant key, or `*` for every current membership), and a session opened under a delegation grant or an elevation is bound to that one tenant for the whole of its life. A tenant the account is not a member of answers the same `404` a tenant that does not exist answers, byte for byte, so membership is never disclosed to a non-member.

With `"profile": "ui"` the answer is `201 {"expires_at", "account"}` with **no** token in the body, and two cookies beside it: `basel_session` (`HttpOnly`, the session token itself) and `basel_csrf` (readable by the page's own script). A browser therefore never stores a bearer token. Every unsafe method behind a cookie session must echo the CSRF value in an `X-Basel-CSRF` header (omitting it is refused `403 csrf_required`; a wrong value is the uniform `401`); the server compares it against a digest on the session row, not against the cookie beside it, so setting both cookies is not enough for a cross-site attacker. `GET` and `HEAD` need no header. The profile chooses the SHAPE of the answer and never the audience the credential was issued at: it is legal for a password login or an `api` credential only, so a transport-only `git` credential cannot become a twelve-hour cookie session.

A bearer header wins over a cookie when a request somehow carries both. Session lifetimes are absolute: one hour for `api`, twelve hours for `ui` (plus a thirty-minute idle window that slides on use), five minutes for `git`, one hour for a control session. An ACCOUNT may hold at most 20 active sessions across every audience at once; opening the twenty-first evicts the oldest.

### Audiences and token prefixes

Every credential and every session is scoped to one of four audiences — `api`, `git`, `ui`, `control` — and every bearer secret announces its kind in its own first four characters, so a leaked string is identifiable without being verifiable. The secret half (everything after the `.`) is never stored: the engine keeps a keyed digest and the public prefix only.

| Prefix | What it is | Which endpoints it opens |
|---|---|---|
| `bss_` | an ACCOUNT session (`api` or `ui` audience), minted by the exchange | every `/accounts/...` endpoint and every `/tenants/{key}/...` endpoint its account is a member of, at its own audience only |
| `bst_` | an `api` credential — long-lived, issued once and shown once | the exchange; trade it for a `bss_` session at `api` or, with `profile: "ui"`, at `ui` |
| `bsg_` | a `git` credential | Git smart-HTTP (`/git/{key}/...`), as `Bearer` or as the password half of `Basic`; and the exchange, which yields a `git`-audience session |
| `bcs_` | a control session, minted by `POST /control/sessions` | `/control/...`, plus `GET /tenants` and `DELETE /tenants/{key}` |
| `bse_` | a one-time password-reset secret, valid 24 hours | `POST /accounts/password-resets/complete`, exactly once |
| `bsi_` | a one-time tenant INVITATION secret — the bearer half of the invitation that creates a membership | `POST /accounts/invitations/accept`, exactly once, and in the request BODY rather than the path |

Beside its audience, every TOKEN credential names the tenants it may be presented to: `tenant_scope` is one tenant key or `*` for every current membership, chosen at issue and never edited. A scope naming a tenant the account holds no active membership in is refused at issue (`not_member`) rather than handed over as a key that opens nothing. A password carries no scope at all — `tenant_scope` is NULL for a password credential and, by a table constraint, for nothing else.

Audiences do not overlap and do not fall back. A `git`-audience session presented at a data-plane endpoint is refused `401 audience_mismatch` — the one refusal that names itself, because its holder already has a valid session and telling them saves an unbounded retry loop. A session whose credential is scoped to tenant A, presented at tenant B, is the silent `401 unauthenticated` — and so is a borrowed session presented outside the one tenant it was borrowed in. A tenant the account is not a member of, and a tenant that does not exist, are one `404`.

### Who you are, and logging out

`GET /tenants/{key}/whoami` answers with the caller as the engine sees them: `tenant`, `account` (the instance identity behind the membership — `id`, `login`, `kind`), `effective_principal`, `acting_principal`, `delegation_grant`, `elevation`, `session` (id, audience, method, expiry), `method`, `audience`, `policy_version`, and the caller's own `security_facts`. Two memberships of the same account carry the same `account.id` and different principal ids, which is how a client tells one person in two tenants from two people. It carries **no** secret of any kind — no token, no verifier, no CSRF value, not even the credential id, which a caller learns from the exchange that issued it rather than from an endpoint any page script can call. No email and no instance role either: those are control-plane facts, answered at the account endpoints. `GET /control/whoami` is its control-plane twin.

Above the tenant, `GET /accounts/me` answers with the account itself — id, login, email, display name, kind, instance roles — and `GET /accounts/me/memberships` lists every tenant this one session reaches, with the membership id, display name and role ids it carries in each. That listing is what a console's tenant switcher is built from, and it is the only endpoint that says which `/tenants/{key}/...` paths a token opens.

`DELETE /accounts/sessions/current` revokes the session the request itself authenticated with and, for a cookie session, clears both cookies. **Logging out logs you out of every tenant at once**: the session row lives in the control plane and every tenant reads it there, so there is no per-tenant logout left to forget. It is idempotent: `204` even when there is no session row behind the context (development mode). `DELETE /control/sessions/current` is its control-plane twin.

### Invitations: where an account comes from

There is no signup. An account is created by INVITATION, and a tenant membership is created by accepting one. A member holding the tenant action `member.invite` posts `POST /tenants/{key}/invitations` with either a `login` (an account that must already exist) or an `email` (one that need not), a `kind` of `human` or `agent`, and the `roles` to assign on acceptance — `role_admin` among them needs `role.assign_admin` as well. The response carries the one-time `bsi_` secret exactly once. Basel sends no mail in this milestone: the inviter conveys the secret. `GET /tenants/{key}/invitations` lists the ones that can still be redeemed and `DELETE .../{id}` withdraws one, so its secret stops redeeming from the next request on.

Acceptance is `POST /accounts/invitations/accept`, and **the secret rides in the body**, never in the path: a URL reaches a reverse proxy's access log, a browser's history and a `Referer` header, none of which Basel controls. Two paths through one endpoint. With a session, body `{"secret"}`: the caller's own account joins, and the invitation must have been issued to exactly them. Without one, body `{"secret", "login", "password", "display_name"}` (optionally `"profile": "ui"`): the invitation's email becomes a NEW account, which is created, joined to the tenant, given its roles and handed its first session — all in one transaction, so a taken login leaves neither a half-made account nor a spent invitation. A request that presents a credential at all takes the first path even if that credential is dead, and gets the uniform `401`: a session that expired must never quietly become a second account.

Acceptance runs under the tenant's own version lock, because the membership it writes is stamped with the policy version its roles were resolved under. A metadata apply that commits mid-acceptance is `409 conflict`; retry with the same secret. On success the tenant records `security:member.join` and `security:role.assign`, and the answer names the tenant key, the MEMBERSHIP id every tenant endpoint and every policy condition is keyed on, and the roles.

Eight refusals, and none of them discloses whether a login or an email exists to anyone but the inviter.

| Status | `code` | When |
|---|---|---|
| 422 | `invitation_invalid` | the secret is unparsable, names no invitation, or fails its digest — one answer for all three |
| 422 | `invitation_expired` | past its `expires_at` |
| 422 | `invitation_consumed` | already accepted, or withdrawn |
| 422 | `invitation_mismatch` | the caller is not the invitee; it names nobody |
| 422 | `managed_account` | the invitee is an account another tenant manages |
| 409 | `login_taken` | the login chosen on the new-account path already exists |
| 409 | `already_member` | that account already holds a membership here |
| 409 | `conflict` | a metadata apply committed mid-acceptance; retry |

The last two are `409` rather than `422` on purpose: the request was well formed and the world disagreed with it. Passwords must be 15–256 characters and are checked against a blocklist; a refusal is `weak_password`. A live EMAIL invitation withdraws itself after five failed acceptances, so the new-account path cannot be walked as an unbounded probe for which logins are taken. Whether a login exists is disclosed in exactly two places — to an inviter naming one, and to an account naming an agent at `POST /accounts/me/agents` — and both are rate-limited, per account and per source address.

**Agents and services.** An agent is an account a person owns: `POST /accounts/me/agents` mints one under the caller's own direct live authority, joined to nothing. A tenant hosts it with `POST /tenants/{key}/installations`, which needs `member.invite` and the owner's consent — install it yourself and the membership is created outright (`status: "installed"`); install somebody else's and the owner gets an invitation to accept on the agent's behalf (`status: "invited"`). A service is the other shape: `POST /tenants/{key}/services` creates an account this tenant MANAGES together with its membership, in one transaction. A service is a member of that tenant and of nowhere else, and it manages none of its own keys — `POST /tenants/{key}/services/{id}/credentials` and its revoke twin are the only endpoints that issue and cut them, under `member.invite` and direct live authority, always scoped to this tenant. A service may still open a session and read `GET /accounts/me` and its own credential summaries; every other account-plane lifecycle endpoint answers `403`. Asked for at a tenant that does not manage it, a service is `404`, like anyone else who is not a member.

**The first member of a new tenant.** `POST /control/tenants` with `{"key", "admin": {"login"} | {"email"}}` provisions the tenant, seeds ONLY the engine principal, and mints the first administrator's invitation in the same transaction. That response is the one and only place its `bsi_` secret appears, and redeeming it is the first thing anyone does with a new tenant. The CLI spelling is `basel tenants create <key> --admin-login <login>` or `--admin-email <address>`.

**The rest of the identity lifecycle** is split the way the entities are. On the ACCOUNT: `POST /accounts/me/credentials` mints a bearer credential — `{"audience": "api"|"git"|"control", "tenant_scope": "<key>"|"*", "label": "...", "expires_at"?: "<RFC 3339>"}` — returning the plaintext exactly once; `DELETE /accounts/me/credentials/{id}` revokes it and every live session it authenticated; `POST .../{id}/rotate` replaces it with a successor carrying the same audience, scope and label and inheriting its expiry, revoking the predecessor and its sessions in the same transaction; `POST /accounts/me/password` changes the password and closes every other session of the account. All four need DIRECT live authority (ADR-0029): a delegated or elevated session manages no durable credential, not even its own. A `control` audience is checked against the `operator` role on the LOCKED account row, not against the snapshot in the caller's context.

On the MEMBERSHIP: `GET /tenants/{key}/principals` is the roster, `POST /tenants/{key}/principals/{id}/active` deactivates and reactivates under direct `principal.manage`, and `DELETE /tenants/{key}/principals/{id}` removes a member — deactivated, never deleted, so the tenant's own history stays readable, and the ACCOUNT and its memberships of other tenants are untouched. Neither endpoint will leave a tenant with no active `role_admin`: that is `422 last_admin`. Instance-wide, `POST /control/accounts/{id}/active` deactivates an account across EVERY tenant in one transaction — a `last_admin` anywhere undoes the whole thing — and `POST /control/accounts/{id}/instance-roles` grants or revokes `operator`, refusing `last_operator` for the last one. Deactivation retains display names, so history stays attributable.

### Delegation

An agent acts *for* a human rather than as one. A human's live session mints a **delegation grant** naming a delegate (a `service` or `agent` principal), an audience, an expiry no later than the delegator's own session expiry, and a mandatory `reason` — a durable audit string, at most 500 characters. Opening a session from that grant yields an `AuthContext` whose **effective principal** is the delegating human and whose **acting principal** is the delegate; `whoami` shows both, and every audit event and attempt row records both.

Delegation is **one hop**: a grant-backed session cannot mint a further grant (`delegation_not_allowed`). Deactivating either principal revokes every grant that touches them and every session opened from one. Every grant carries an immutable normalized scope. Optional `scope` accepts `roles` bundles plus `objects`, `fields`, `tenant` and `permits` in policy grammar; each inline permit names its `object`, `actions`, optional `write`, `when` and `after`. Omitted scope snapshots the delegator's current role closure; an empty explicit scope grants nothing. Role widening and new wildcard fields never widen an old cap.

Delegation is also **one tenant**. A grant is written between two MEMBERSHIPS of the same tenant and checked inside that tenant's transaction against that tenant's roles, so it confers nothing anywhere else — and the session opened from it is bound to that tenant for the whole of its life, even though the delegate's account may be a member of several. Presented at any other tenant a borrowed session is the silent `401`, and no reach is created for it there. The same is true of an elevation. A borrowing is not an identity, so it does not travel with one — and for the same reason a borrowed session opens no endpoint on `/accounts/**` at all: acting as the borrowed account there would be impersonation, so every account endpoint answers the uniform `401`.

The endpoints: `POST /tenants/{key}/grants` mints one from the caller's own live session (`{delegate, audience, expires_at, reason, scope?}`), `GET /tenants/{key}/grants` lists the ones the caller is a party to, `POST /tenants/{key}/grants/{id}/sessions` turns a grant into the delegate's session, and `DELETE /tenants/{key}/grants/{id}` revokes it along with every session it opened. Either party may open or revoke. A grant you are not a party to answers `404`, not `403`: whether somebody else's grant exists is not yours to learn.

### The control plane and elevation

The control plane is not a separate identity space any more. An **operator is an ordinary account holding the `operator` instance role** — the same `basel_core.account` row that may be a member of tenants, with the same login and password — and the separate control-principal table is gone. What is separate is the AUDIENCE: `POST /control/sessions` opens a `bcs_` session, an hour at a time, and the role is re-read on every use, so an account that loses `operator` stops being able to use the control session it already holds. The control plane is where tenants and accounts are created and deleted, and it keeps its own attempt sink in `basel_core`. A control session opens nothing inside a tenant on its own: presented at a tenant endpoint it is the silent `401`, and a tenant session presented at `/control/...` is the same. The last active operator cannot lose the role (`last_operator`), for the same reason a tenant cannot lose its last admin.

The bridge between the two is an **elevation**, and it is auditable by construction. `POST /control/elevations` takes `{"tenant", "tenant_principal", "reason", "ttl_seconds"}` — the reason may not be blank — and returns a *tenant* `api` session bound to the elevation row it just wrote, capped at one hour. Every request that session makes, and every refusal it collects, names the elevation. `DELETE /control/elevations/{id}` hands the borrowing back, and the tenant session stops resolving on its next request. Borrowed authority may not be turned into durable authority: an elevated session cannot mint a credential — not even for the principal it borrowed — and cannot delegate.

The very first operator comes from the server itself: on a database where no account holds `operator`, `basel-server` mints a `control-admin` account and prints exactly one `BASEL_CONTROL_ENROLLMENT=<bse_ secret>` line on stderr and nothing else. Redeem that secret at `POST /accounts/password-resets/complete` — on the ACCOUNT plane, like every other password reset — to set its password. It is printed once, on the first start only.

### Development mode

`BASEL_SECURITY_KEY` (base64 of exactly 32 bytes) is required at startup; the server refuses to start without it, because every credential digest in the database is keyed with it. There is no generated default: a key that changes silently invalidates every credential the deployment holds.

With **all three** of `BASEL_DEV_MODE=1`, a loopback peer address, and `BASEL_DEV_PRINCIPAL` naming an ACCOUNT login, every tenant request authenticates as that account's membership in the path tenant with no token exchange at all — and does so even if it carries a real token. The membership is created on first use, with `role_admin` if and only if the tenant has no active admin yet, so a freshly provisioned tenant is usable without dev mode becoming a way to mint authority in a tenant that already has an administrator. Any one of the three missing falls through to ordinary authentication rather than refusing. The server refuses to start with `BASEL_DEV_MODE=1` and a non-loopback bind.

Two development-only endpoints exist under the SAME two conditions — `BASEL_DEV_MODE=1` and a loopback caller — and are absent otherwise. Absent literally: without `BASEL_DEV_MODE=1` the router holds no such path, so every request to one, by any method and with any body, is the same `404` an unrouted path earns, and a production instance discloses nothing about either. `POST /tenants/{key}/dev/members` `{"login", "display_name"?, "roles"}` creates a credential-less account — no password, no token — and its membership holding those roles, answering `201` on creation and `200` on reuse; the `roles` it echoes are the ones this call assigned or confirmed, not necessarily every role the membership holds, because the endpoint never removes one. `POST /accounts/dev/sessions` `{"login", "audience"?, "profile"?}` then opens an ordinary session for it, with `method: dev` and no credential behind it, in whichever of `POST /accounts/sessions`' two shapes `profile` asks for. Both write the ordinary `member.join`, `role.assign` and session history, so a tenant's audit trail stays truthful about how the identity came to exist. They exist for local load testing; a production engine never has them.

There is still no development shortcut for the AUTHORITY behind `/control/...`: a dev-mode server is still a server that can delete tenants. `POST /accounts/dev/sessions` will open a `control`-audience session, which is what lets a local harness provision its own tenant without a password — but only for an account that already holds the `operator` instance role, and no dev endpoint hands that out. The shortcut is past the password, never past the role.

**A development session can mint only `git`-audience credentials, each expiring within an hour.** A `dev` session reaches `POST /accounts/me/credentials` like any other, and what it mints there is an ordinary row in the credential table — no mark of where it came from, and nothing about a restart without `BASEL_DEV_MODE=1` takes it away. So the endpoint is narrowed at both ends: every audience but `git` is refused, and the credential's `expires_at` is capped at one hour from issuance whatever the request asked for (a request naming none gets the hour anyway). `git` is the audience that survives the narrowing because it exchanges only into a `git` session, which opens `/git/{key}` and no account or control endpoint — so it cannot be walked back up into the authority the dev endpoints withhold. Label such a credential anyway and revoke it at `DELETE /accounts/me/credentials/{id}` when you are done: the load harness labels its git credential `basel-load-harness` and deletes it in a `finally`, so an interrupted run leaves nothing behind. The two neighbouring endpoints that would have minted around this rule are refused from a development session outright: rotating a credential (`POST /accounts/me/credentials/{id}/rotate`, whose successor would inherit the predecessor's audience and expiry) and keying a tenant's service (`POST /tenants/{key}/services/{id}/credentials`, whose credential belongs to the tenant and outlives the session entirely).

One more thing lands on a developer's terminal: on a database where no account holds `operator` the server prints `BASEL_CONTROL_ENROLLMENT=<bse_ secret>` on **stderr**, once. That is a live credential in a place terminals scroll back through and log collectors follow, so redeem it promptly and rotate the password afterwards rather than leaving the line sitting in a buffer.

### Refusals, and finding out why

Authentication refusals are deliberately uniform. Every one of them — no credential, a malformed token, a revoked or expired session, an unknown login, a wrong password, a token presented outside its `tenant_scope`, and a borrowed session presented outside the tenant it was borrowed in — renders the identical body, `401 {"code": "unauthenticated", "detail": "authentication required"}`, with `WWW-Authenticate: Bearer` (a bare scheme: a `realm` would name the tenant). The one neighbouring answer that is NOT a 401 is uniform for the same reason: a tenant that does not exist, a tombstoned tenant, and a tenant the authenticated account holds no membership in are one `404 {"code": "not_found", "element": "tenant", "detail": "no such tenant"}`, byte for byte, and so is any unmatched `/tenants/...` path. Nothing in a refusal discloses whether a tenant, login, principal, or record exists.

| Status | `code` | When |
|---|---|---|
| 401 | `unauthenticated` | every authentication failure, uniformly — see above |
| 401 | `audience_mismatch` | a session that resolves at another audience; the one refusal that names itself |
| 403 | `forbidden` | an authenticated caller refused an operation — by tenant policy (see "Policy"), by a missing tenant action, or by one of the identity rules the security plane keeps outside policy: credential and password lifecycle needs DIRECT live authority, so no delegated or elevated session manages a durable credential, not even its own; and a `service` account manages none of its own at all, because the tenant that manages it holds its keys |
| 429 | `rate_limited` | the login/token flood gates: 10 attempts per minute per login or token prefix, 60 per minute per source address |
| 415 | `unsupported_media_type` | a JSON endpoint handed a body with no `Content-Type: application/json` — checked before the body is read |
| 500 | `authorization_evaluation_failed` | authentication could not be *evaluated* (a database fault), which is not a refused credential |

Every refusal the security plane itself decides — each `401`, the `403` above, and a `429` from the flood gates — carries an `X-Basel-Trace` header, a uuid naming the durable row that recorded it. (`415` is refused before authentication runs, and carries none.) The body still says nothing; the header says *which* nothing, so a caller can quote it and an operator can look it up. (The Git transport is the one exception: its 401 stays byte-identical to what a `git` client expects, so the row is written but no header is stamped.)

That row lives in the **attempt log**, which is separate from the audit log: audit records what happened, attempts record what was refused. `GET /tenants/{key}/attempts?actor=&action_name=&code=&limit=` reads a tenant's own; `GET /control/attempts` reads the instance sink — every refusal belonging to no tenant, plus every refusal naming a tenant key that resolves to nothing, which is what makes tenant enumeration visible to an operator. Rows carry the matched route *template* rather than the concrete path, a fixed `code`, the actor when one was identified — the membership id, and the ACCOUNT id as well once one is resolved, so an operator can correlate one person's refusals across tenants without joining schemas — and no free text at all.

No password, no credential verifier, no session or CSRF secret, and no reset or invitation secret appears in any reflection, query, history, audit event, attempt row, log line, or error body. A plaintext bearer secret is returned exactly once, by the call that mints it, and never again.

## Policy

**Reads are checked; write enforcement is partial.** Every actor-facing read uses a compiled per-principal program for objects, rows and fields. Direct object and relationship-property updates now enforce complete permits over locked records before and after the write, all requested fields (including no-ops), and fresh session/role facts. Successful audit records determining permits. Direct object and relationship creates check the completed record after the write; deletes check the locked record before the write. Delegated operations intersect both principals' current permissions with the immutable grant cap. Object and relationship actions check invocation authority before privileged effect/function reads and again after locking; admitted consequences need no primitive update permit and retain caller attribution. Nested/raw client record operations require `proposal.submit` and their own per-operation create/update/delete witnesses; Link/Unlink require the relationship's create/delete witnesses even when canceled within the batch. Raw transitions must use the action endpoint. Source link/unlink and independent target reference are checked alongside the relationship's witnesses. M6.2b enforces record shares and bounded manager hierarchy. Metadata installation requires direct current push/admission authority and exact flagged-plan acknowledgment. Expanded explain, actor-aware UI and operator administration remain M6.3 work.

**Existing row targets require visibility.** Every direct row write endpoint — `PATCH`, row `DELETE`, and action invocation, measured or not — probes its target with the caller's own hiding filter spliced into the version check that reads the row. The probe is **unconditional**: it runs whether or not an `If-Match` came with the request (only the version *comparison* needs one), and whether the action touches one record or many. A row outside that filter is not found by the probe, so the write answers `404` in a body identical to a nonexistent id: the same concealment reads get, on the same rows, for the same reason. **And regardless of the object verdict** — the probe hides rather than refuses, so an object you hold no `read` permit on at all answers `404` on a write and `403` on a read, the one place the two rules deliberately diverge. Source-addressed relationship operations use the declaring source plus target reference instead: a hidden but referenceable far endpoint can participate, without making its link ordinarily readable. A share grants only its transferred row actions and never widens read fields. Shared updates use a frozen field cap intersected with the recipient's current readable fields. A transferable update permit has no postcondition or an explicit `after: true`; inherited conditional postconditions cannot be transferred. Grantor access loss does not revoke a share; explicit revocation, expiry and record deletion do.

Invocation consequence validation retains platform codes but uses fixed detail and `invocation` as its element. Conflict diffs omit runtime state. Static declared function, guard and invariant messages remain available; currency mismatch diagnostics do not disclose runtime currency values.
Generic CRUD and nested/raw transaction failures use the same safe boundary with `write` as the element and fixed consequence detail. Pre-transaction model/input errors retain field guidance; transaction-time conversion and integrity errors retain their codes without internal object names or runtime values.
`reference_ineligible` and `constraint_violated` are not exempt from that boundary: the wire response keeps the real code, but its `element` is rewritten to `write` (or `invocation`) and its detail to the fixed consequence text on every non-engine endpoint — the `{Object}.{attr}` / `{Object}.constraints.{name}` position survives only for the tenant's own `engine` actor.

A relationship mutation needs the relationship's create/delete authority, link/unlink on its declaring source traversal, and reference on the target endpoint's reverse traversal. Target-wide `reference` also licenses that target and is required for explicitly supplied non-null ordinary ref fields, including no-op assignments; clearing a ref uses the source field update witness. Direct link CRUD uses the canonical from end; nested writes retain their declaring direction. Source-object denial is 403, hidden/missing sources and missing/reference-forbidden targets are 404. Unknown/ambiguous client link probes also return 404. Reference access never grants ordinary target row or field reads; post-write projection can return an identity-only receipt.

### The `policies/` tier

Relationship metadata also declares `operations:`; omitted means no generic verbs. Object and relationship policy files grant exposed writes and named actions. Property updates require complete field permits; relationship actions require invocation permits and retain their admitted internal effects. Endpoint fields remain immutable, and reads still require both endpoints to be visible.

Role `fields:` maps also name relationships, including properties and `from`/`to`. Omitted readable fields expose only the link id; both endpoint rows must still be visible. Queries, history, write readback and link aggregate values obey these readable fields.

`old.` is accepted in an update-only permit's `when`, or in `after` when the permit includes update. It names the same operation's record immediately before the write and reaches only the object's own stored values, not relationship hops or formulas. Ordinary read policies and BQL still refuse it. Create `when` and update `after` (including inherited `when`) refuse bare reads of unrecomputed own rollup targets, including Money members. Explicit `old.` and before-the-write-only reads retain stored semantics; traversals see frozen database facts. Final derived constraints belong in guards/invariants.

A create checks its permit's `when` and any authored `after` together on the completed record after the write; both must pass. `after` on a permit granting neither create nor update, and `old.` in an `after` on a permit that includes create, refuse at push. Hops in a permit condition read frozen pre-write facts, so a relationship traversal from the record being created reads NULL and never grants: check the parent on its `relationship.<end>.link` permit or in a create guard. Guards, unlike permits, read hops through the record after the whole write, so a create guard sees a parent created or linked in the same proposal.

An action's function may propose a transition the caller could not invoke directly: the action's `invoke` permit and the function's write scope authorize it, and transition guards still apply. `/reflect/authority` lists these transitions under `function_reachable_transitions`, and the grant matrix repeats them on each `action.<name>.invoke` cell, so read both when reviewing who can move a record between states.

`GET /tenants/{key}/authz/declared_grant_matrix` requires `metadata.read` and groups complete permits by role, object and action, with inclusion and readable fields. Its `effective_permissions: false` is deliberate: conditions are not evaluated, and principal memberships, records, shares and delegation are absent. Use its `policy_version` and both checksums to identify the metadata; do not use a matrix cell as authorization to execute an action.

Policy is metadata, pushed through the same Git endpoint as the rest of the repository and compiled in the same metadata version as the model:

| File | What it declares |
|---|---|
| `policies/roles.yaml` | every role: stable id, `includes:`, `objects:` shorthand, `fields:` readable fields, `tenant:` actions |
| `policies/{Object}.yaml` | that object's conditional permits |
| `policies/assertions.yaml` | structural expectations the planner enforces |

The resolved policy carries its own checksum, published beside the model's: a policy edit is **not** a schema change and never moves `model_checksum`, so `/tenants/{key}/reflect/authority` reports both and you need both to say what you read.

### Roles

```yaml
# policies/roles.yaml
roles:
  sales_rep:
    id: role_sales_rep          # stable; the key is the display name
    objects: {Customer: [read], Contact: [read]}   # unconditional row permits
    fields:                     # the readable fields, per object
      Customer: ["*"]
      Contact: [name, email]
  sales_manager:
    id: role_sales_manager
    includes: [sales_rep]
    tenant: [metadata.read, audit.read]
    fields: {Salesperson: [name, team]}
```

`includes:` forms a DAG — cycles are refused `role_cycle`, depth above eight `role_depth` — and holding a role is holding everything it includes. Reflection publishes both forms: the declared one you edit and the flattened closure the engine checks with.

**Two roles are built in.** `authenticated` is the virtual membership every active principal holds; it may carry `fields:` and `tenant:` but no `objects:` shorthand, and out of the box it **grants nothing** — notably not `metadata.read`, so a principal with no other role cannot read `/reflect/**` or the `/ui/**` catalog (an installed app's own `/ui/**?app=` surfaces also open to a holder of one of that app's package roles — see Apps from packages). `admin` (`role_admin`) holds every action on every object, every set of readable fields, and every exposed tenant action; it cannot be declared and no role may include it.

**Membership is data, not metadata.** A role id is assigned with `PUT /tenants/{key}/principals/{id}/roles/{role}` and taken away with `DELETE` on the same path, both under the tenant action `role.assign` — plus `role.assign_admin` for `role_admin` itself. The tenant's last ACTIVE admin cannot be unassigned or deactivated (`last_admin`); borrowed authority — a delegated or elevated session — may not change membership at all. Because membership names the stable `id`, renaming a role in `roles.yaml` orphans no assignment; an id the current metadata version no longer declares simply grants nothing.

### Permits

```yaml
# policies/deal.yaml
object: Deal
permits:
  - name: rep_own_pipeline
    roles: [sales_rep]
    actions: [read, update, action.qualify.invoke]
    write: [name, stage]        # omitted means none; ["*"] means all eligible
    when: owner = actor         # the record before the write
    after: owner = actor        # the record after the write (create's too); update inherits `when` when omitted
```

**The action vocabulary is closed** and per object: `read`, `create`, `update`, `delete`, `reference`, `relationship.<traversal>.link`, `relationship.<traversal>.unlink`, `relationship.<traversal>.reference`, `action.<name>.invoke`, the reserved `action.<name>.approve`, and `share`. A token is only nameable when the object actually exposes it: the generic write verbs come from the object's `operations:` (omitted means none), a traversal must be declared on a relationship touching the object, `action.<name>` must name a declared action or lifecycle transition, and `share` needs `sharing: true`. Anything else is refused `unknown_action` at push.

**There is no forbid rule.** Effective authority is the union of the matching permits, inside the readable fields, inside the structural readable fields (tenant, principal active, session audience, delegation scope). Adding a permit never removes authority, and "why may this actor do this" always has a named permit as its answer. **A permit is atomic**: its roles, actions, `write:` list and conditions are never recombined with another permit's. A permit that could grant nothing at all is refused `grants_nothing` — a `read` permit whose roles have no `fields:` readable fields on the object, or a `create`/`update` permit whose `write:` list is empty.

### The readable fields

A role's `fields:` is its **readable fields** on one object: `["*"]` for every eligible attribute, a named list otherwise, and an object absent from `fields:` has no readable fields there at all. An actor's readable attributes are the union of the readable fields of the roles they hold. It depends on roles alone — never on a row, never on a permit's condition — so it is stable for the whole request. A formula is readable only when every attribute it transitively reads is: naming a formula in a role's readable fields does not license its inputs. An attribute outside the readable fields is **omitted from the response, never nulled**, and a BQL query that names one is refused `unreadable_attribute` rather than silently narrowed. The one exception is a generated UI list query, which leaves out the columns and sort terms the caller cannot read (see Apps from packages).

### Conditions: the actor root and `IN <path>`

A permit's `when`/`after` is an expression in the shared grammar (see "Guards" and "BQL") under its own legality profile. It gains one root the rest of the language does not have: **`actor`**, the effective principal of the request, usable bare (`owner = actor`) and as a path (`actor.team`, `actor.manager.id`) through declared relationships from `Principal`. `IN <path>` is set membership against a to-many traversal — `actor IN team.members`, `owner IN actor.reports` — and is legal only where the condition is compiled to SQL, which is exactly where policy conditions are checked; a *guard* that writes `IN <path>` is refused at resolve, because guards are checked in memory. A condition must compile to bounded, parameterized SQL (one that cannot is refused at push), may use transaction-stable `NOW()`, and may not use history functions or unbounded aggregates. `NULL`, `FALSE`, or an evaluation failure grants nothing.

### What the read filter does to a caller

The same program filters every actor-facing read: `GET /tenants/{key}/objects/{Object}/{id}`, `POST /tenants/{key}/query`, per-record history, row versions, the evaluated UI's named queries and their counts, the record-narrowed invocation log, and `GET /tenants/{key}/principals` — the identity roster is a read of the built-in `Principal` object and is checked as one, so the canonical `when: id = actor` policy answers a listing of exactly one principal: the reader. A membership names an account, and `login` and `account_kind` are projected onto `Principal` from it as ORDINARY attributes: readable, filterable, orderable and traversable like any other, and bounded by the role's field readable fields in exactly the same way. A role whose readable fields omit `login` reads members without logins, and BQL `select: [login]` under that role is `422 unreadable_attribute` — which is how a tenant hides logins from a role, since there is no separate switch for it. Every identity **lifecycle** endpoint that names a principal id checks that target's visibility first and answers `404` for one it hides, in the body a nonexistent id earns: activation, membership removal, service credential issuance and revocation, grant creation, and the three role endpoints — which gate their tenant action *before* they look, so a caller without it still learns nothing about who exists. You are always visible to yourself, and your own credentials live on the account plane, so managing them needs no `Principal` read permit at all. Principal activation requires target visibility and direct `principal.manage` authority; it has no self-service exception.

**The 404/403 rule.** A row your filter hides answers `404`, in a body identical to one that never existed — for every method, so probing tells you nothing. An object you hold no `read` permit on at all answers `403 forbidden`, naming the object and nothing else: its existence is already public through reflection, but no refusal ever names the permit, condition, or row that decided. `/reflect/authority` is where authority is explained; a refusal is not.

**Audit reads.** The built-in `Event` pseudo-object as a **query source** needs the tenant action `audit.read`; **per-record history** (`GET .../{id}/history`) needs no such action and is filtered by the record's own visibility instead, as is a record-scoped activity feed. A feed or history page whose events were redacted away comes back **shorter** than the page size without changing `has_next` — so page until `has_next` is `false`, never until a page looks short.

**Cursors.** A UI query cursor is signed and version-bound. **Any apply invalidates every outstanding cursor** — the next page request answers `stale_cursor`, and the client restarts from page one. Rotating `BASEL_SECURITY_KEY` invalidates them too, as `invalid_cursor`. Neither is an error to retry; both mean start over.

**Operator endpoints.** `/tenants/{key}/diagnostics/**` needs `diagnostics.read`; `/tenants/{key}/attempts` needs `security_attempt.read` (M6.3a; it used to be `audit.read` — the moved gate) while `Event` as a query source and per-record history keep `audit.read`; the M6.3a authority surfaces add six new tenant actions to that vocabulary — `authz.explain_full` (the full explain view), `authz.explain_others` (explaining another principal, or a record the caller cannot see), `authz.lookup_subjects` (`/authz/subjects`), `security_attempt.read` (above), `audit.export`, and `security_attempt.export` (the two tenant exports; the instance twin is operators only, gated directly, no lookup); `/tenants/{key}/invocations` needs `audit.read` unless you narrow it to one `record`, which makes it an ordinary read under that record's own visibility. **Everything that serves the metadata tier needs `metadata.read`**, in whatever form it serves it: `/tenants/{key}/reflect/**`, the `/tenants/{key}/ui/**` catalog (save an installed app's own surfaces for that app's role holders — see Apps from packages), `/tenants/{key}/metadata` and its version endpoints, `/tenants/{key}/git/status` and `/tenants/{key}/git/receives`, and the Git **read** transport itself — `info/refs` for **both** services, `git-upload-pack`, and the `plan` preflight, which refuse with a plain `403` and no `WWW-Authenticate` so a `git` client does not retry a credential that is already good. A git-audience credential authenticates a clone; it does not authorize one, because the repository is `policies/`. Discovery is checked whichever service asked for it: the advertisement names refs and the commit ids they point at either way, and a principal who may push `policies/` must be able to read it anyway. Push requires direct live `metadata.push`. Flagged authority expansions additionally require current `policy.admit_expansion` or `function.admit_scope` and the exact `basel-plan` acknowledgment; proposed permissions cannot authorize their own installation. None of these is granted by `authenticated`, so an ordinary principal reads none of them.

**Delegation intersects three authority sources:** the acting agent, the effective human and the immutable grant cap. Each principal's conditions bind `actor` to that principal; cap conditions bind it to the human. Reads, readable fields, writes, actions and tenant actions must pass every source. Current role narrowing reduces old grants. Elevation continues to use the borrowed tenant principal's permissions.

### Assertions

```yaml
# policies/assertions.yaml
assertions:
  - roles: [sales_rep]
    cannot: action.reopen.invoke
    on: Deal
  - roles: [authenticated]
    never_reads: [Salesperson.salary]
```

An assertion is a structural expectation about the policy set, and the **planner evaluates it against the policy the push proposes**, before anything is written. Both shapes are checked after flattening: `cannot`/`on` fails when some permit grants that action on that object to any role the named roles hold, and `never_reads` fails when the flattened readable fields of a named role admits that attribute. A violation refuses the push with `assertion_failed`, addressed at `policies.assertions[N]` and naming the roles and the action or attribute involved. An assertion that names a role, object, action or attribute that does not exist is refused earlier, as `unknown_role`, `unknown_action`, or `unknown_attribute`.

### `/tenants/{key}/reflect/authority`

The tenant's complete declared authority, under `metadata.read`: every role (`id`, `builtin`, `includes`, the flattened closure, `objects`, `fields`, `tenant`), the closed `tenant_actions` vocabulary, every object with its `operations`, `sharing`, and the permits naming it — each permit's `roles`, `actions`, `write`, and its `when`/`after` as authored **source text** — and every assertion with the element the planner would refuse it at, plus `function_reachable_transitions`: the lifecycle transitions each action's function can propose on the strength of the action's own `invoke` permit alone. Both `model_checksum` and `policy_checksum` are on the body. It is structural and complete: it describes what was declared, never who holds it and never any record. For who *you* are and which roles you hold, read `GET /tenants/{key}/whoami`.

### Explaining a decision

`POST /tenants/{key}/authz/explain` (spec §3.2) answers, for one of six shapes — `read`, `delete`, `action.<name>.invoke`, `update`, `create`, `relationship.<name>.link` — what the subject's own read program or write check would decide, by running that endpoint's OWN program: a read is checked by `ReadPolicy`, a write by the same write check `commit::authorize_candidates` reaches, over the endpoint's own completed records before and after the write. There is no second evaluator; if explain and the endpoint ever disagree, the endpoint changed underneath it. Explain follows the orientation of the action token the caller asked about — a `relationship.<name>.link` is checked from whichever end the traversal names — and it does not apply `proposal.submit`, because the record endpoints its six shapes model never require it either.

**Bounded and full.** The bounded view is `decision` (`permitted`, `denied`, or `not_found`), the `action` asked about, the subject, `policy_version`, both metadata checksums, the record (with `version_mismatch` for an `update` whose caller-supplied version is well-formed but not the row's current one — never a refusal; the check always runs on the current record regardless), `determining` — the permit ids and `share:`-prefixed share ids that decided it — and, on a denial only, `code`: the refusal code the endpoint itself would return. A refusal raised before any permit is consulted at all (the subject is not live, say) cannot name one, so `determining.permits` is empty there — that is the check-level case, not a missing answer. Holding `authz.explain_full` on top flattens in every candidate permit that names the action, held or not, each with its `when`/`after` outcome (`"true"`, `"false"`, or `"unknown"` for a permit the widened check pass never reached), the write-field cap (`declared`/`requested`/`uncovered`), the SUBJECT's own readable fields, and the rendered `row_filter` — both drawn from the subject's own program, the answer being asked for, never the caller's — and the record's live shares.

**`principal`.** Naming another membership's principal explains THEIR authority, not the caller's, and needs `authz.explain_others` — checked before the named principal is even looked up, so explain cannot be used as an existence oracle for memberships: a nonexistent uuid and a real one refuse identically without it. The record is still fetched under the **caller's** own read program (the non-disclosure gate protects the caller, who must not learn that a record they cannot see exists), and the check then runs under the **named subject's** program — which is the whole point: every interesting question is about a record the subject cannot reach.

**The byte-identical 404.** A record the CALLER'S own filter hides answers the record endpoint's own `not_found` body, unchanged down to the wording, because it is the same function. Without `authz.explain_others` a hidden record refuses outright with that body; with it, the identical body rides inside an ordinary `200` as `decision: "not_found"`, since the action already answered the one visibility question the caller is allowed to ask about someone else. **Explain never writes.** It opens a transaction so the predicate evaluator can lower every `when`/`after` into one statement, and rolls it back unconditionally on every path — success, denial, or a database error alike — so no audit event, attempt row, or session touch is ever left behind by a call to it. **Both widening actions go through the delegation cap**, exactly as every other tenant action does: under a grant-scoped session what counts is the intersection of the effective principal's tenant actions, the acting principal's, and the frozen grant scope, so a delegated caller whose scope omits `authz.explain_full` gets the bounded view and one whose scope omits `authz.explain_others` cannot name another principal — whatever the delegator holds.

### Who can do this

`GET /tenants/{key}/authz/subjects?action=&object=&record=&after=&limit=` (spec §3.3, gate `authz.lookup_subjects`) inverts a permit's own condition into a query over memberships rather than approximating it. Reversible shapes: `<record path> = actor`, `<record path>.id = actor`, `actor.<attr> = <literal or record path>`, `<record path> IN actor.<traversal>` and its mirror `actor IN <record path>`, and conjunctions/disjunctions of those. A comparison naming no actor at all is an ordinary record predicate and reverses trivially, whatever its operator. Refused as a whole: an actor compared any way but `=`; a NEGATED actor reference (`NOT (owner = actor)` asks for every principal the row does *not* name, which no join over memberships produces); a quantifier over a traversal; and an actor inside a computed value or an aggregate. A permit the planner cannot reverse answers `422 {"error": "unsupported", "code": "unsupported", "element": "<permit id>", "detail": "the permit's condition cannot be reversed into a subject query"}` for the WHOLE request — the endpoint never returns a partial list beside a permit it cannot reverse.

When every applicable permit reverses, the answer is the union over those permits plus every live share on the record whose actions include the one asked about — one row per membership: `principal`, `kind`, `display_name`/`login` under the caller's own `Principal` readable fields, and `via` naming the permit id or `{"share": "<id>"}` that admitted it — paged at a default of 100 and a cap of 500, ordered and cursored by principal id. `complete` is `false` whenever a cursor was returned, the cap was hit, or a subject was dropped because the caller's own `Principal` program hides them, and `next` is always present in the body (`null` once the page is complete) so a client never has to special-case a missing key. A deactivated membership, one whose account is suspended, or the tenant's own engine principal is never a subject. Naming no `record` lists every principal an UNCONDITIONAL permit admits on every row, and reports every conditional permit's own id under `conditional_permits` instead of expanding it. Because a role's `objects:` shorthand is a real unconditional permit, `role_admin`'s holders are subjects of every record the shorthand reaches — often including the caller who asked. **Hierarchy is not expanded**: no check reads the principal hierarchy closure yet, so subject lookup mirrors exactly what a check would decide today, no more.

### Exports

`GET /tenants/{key}/audit/export` (`audit.export`), `GET /tenants/{key}/attempts/export` (`security_attempt.export`), and the instance twin `GET /control/attempts/export` (operators) answer the same shape (spec §3.4): a required RFC 3339 `from`/`to` window at most 31 days wide — absent, inverted, empty, or over-wide is one refusal, `422 invalid_window` — a `limit` (default 10 000, max 100 000, CLAMPED rather than refused), `200 application/x-ndjson`, one JSON object per line oldest first, and a final summary line `{"export": {"rows": N, "complete": bool[, "next_from": "...", "next_after_id": "..."]}}`. The query asks for `limit + 1` rows; when the extra one comes back the page answers `complete: false` and names the LAST KEPT row's `(occurred_at, id)` pair as `next_from` and `next_after_id`. Resume by handing BOTH back, as `from` and `after_id`: the page then starts strictly after the row you already read. Both halves are required — `occurred_at` alone is not unique, because one transaction stamps every row it writes with the same `transaction_timestamp()`, so a resume on the timestamp alone would re-serve the same page forever once a single instant held more than `limit` rows. The summary line carries the fields too, so a caller that never looks past it still cannot mistake a page for the whole window. Export bodies are materialized in full up to the cap rather than streamed to the wire; the backlog carries the streaming follow-up.

**Never more than the ADR-0022 public projection.** Every field that could carry a secret, a handle to one, or free text a person typed — session ids, credential ids, tokens, verifiers, trace ids, `reason`, request bodies, `parameters`, `proposals`, `action_name`, `grant_id`, `elevation_id`, `method`, `detail` — is not merely filtered out of an export row; it is never selected. An audit row carries `id`, `occurred_at`, `operation`, `object`, `record`, `actor` (`principal`, `account`), `policy_version`, and `affected` (every record the event wrote). An attempt row carries `id`, `occurred_at`, `action`, `object` (the object the refusal was about — the object a denied read named, `Event` for a query the `audit.read` gate turned away, or the route's own `{object}` segment; `null` when the refusal reached no object at all, which is every authentication one, and every refusal that is not about an object), `result`, `code`, `actor`, `audience`, `policy_version`, and `determining_permits`; the instance twin's row additionally carries `tenant`, present only there. Every id on the wire is a plain uuid, never a `TypeID`.

**Exports are themselves audited acts.** Each call writes exactly one event — `security:audit.export`, `security:attempt.export`, or the control plane's `control:attempt.export` — naming the window, the row count, and whether the cap cut it, and never the rows read. The `SELECT` runs before that event is inserted, in the same transaction, so an export never contains its own receipt and always appears in the NEXT export over an overlapping window.

### `permitted`

A record response can carry a `permitted` block — `{"actions": [...], "writable_fields": [...]}` — answering, for the row already returned, what this actor may do to it NEXT and which of its attributes they may change: checked by the same write check every write path reaches, over the endpoint's own completed records, and rolled back unconditionally like explain (no audit event, no attempt row). `actions` always starts with `read` (a fact — the row already passed the caller's read program), then `update`, then `delete`, then each declared `action.<name>.invoke` in name order, each included only when the check allows it; `create`, `share`, `reference`, traversals, and `action.<name>.approve` are never checked here. `writable_fields` is the check's own field witnesses minus whatever any layer — a role permit, a delegation cap, or a share's own field cap — left uncovered, rather than a union computed beside the check; a row reached only through a share therefore reports exactly the fields that share transfers, and a delegated write reports exactly what the grant still covers, never what the underlying role alone would.

**Where it appears.** Every `GET /tenants/{key}/objects/{Object}/{id}` carries it unconditionally. A UI query row carries it only on request — `include: ["permitted"]` in the query body, refused `unknown_include` for any other name this metadata version does not serve — except a query bound to one record's own context, which gets it appended whether asked for or not, the same as the single-record endpoint. **How a form should read it.** A generated `view`/`edit`/`create` surface — gated commands and forms over writable attributes — always `consumes_permitted`; a `list` surface does not, because a list gates nothing per record and asks for the block per query instead when it wants one; a hand-built page `consumes_permitted` exactly when some bound component in its tree WRITES a field (the compiler's own `UiBinding::writable`), so a read-only dashboard or report never claims authority it never asks anyone to exercise. A form that renders a control before `permitted` answers is guessing; one that offers an action absent from `actions`, or a field absent from `writable_fields`, is offering something the endpoint will refuse.

## Ids: TypeID on the wire

Every externally emitted engine-owned `id` and every `ref` value is `TypeID`-formatted: `<snake_object>_<26-char base32 suffix>` — e.g. `widget_01h455vb4pex5vsknk084sn02q` for a `Widget` row. This is a wire-only relabeling of the stored `uuid` (which itself defaults to `uuidv7()`, not random `v4`); the prefix is the same `snake_case` name reflection already uses for the object's table. A declared `uuid` attribute is ordinary user data, not an engine-owned id — it stays bare, both directions (`BSL-REQ-25`).

Every id-accepting input — a `ref` value in a create/update body, `{id}` in a `GET`/`PATCH`/`DELETE` path, or a `UUID '...'` literal compared against `id`/`path.id` in BQL — accepts **either** form: the `TypeID` a prior response returned, or a bare uuid. A `TypeID` whose prefix disagrees with what the context expects (a ref's declared target object, the route's `{Object}`, or an `id` path's target object) is refused `422 id_prefix_mismatch` — in BQL, an `bql_compile_error` with that same code — before any database access, never silently coerced to the "right" object (`BSL-REQ-25`).

In BQL specifically, `id` and `<path>.id` are pseudo-attributes (`BSL-REQ-23`), resolvable in projection and `WHERE` everywhere a declared attribute would be — see "BQL" below for their full operator set and the `ORDER BY` exclusion.

## Error shape

Every error response is one shape, carrying the metadata element at fault:

```json
{"error": "refused",
 "code":  "removal_not_supported",
 "element": "Widget.legacy_code",
 "detail": "declared in version 6, absent in submitted document; removing elements is not supported in M1"}
```

A refused metadata plan additionally carries a top-level `"refusals"` array: one entry per refused element, each with its own `code`/`element`/`detail`, so a caller can act on every refusal in one response rather than fixing one and re-submitting to discover the next.

**Guard failures (M2, `BSL-REQ-42`):** a write refused by a guard's `when` (design doc §5.6) is `422` with `code: "guard_failed"`, `element: "<Object|Relationship>.<guard name>"`, and `detail` — the guard's own `message` when it declared one, else a derived detail (`BSL-REQ-47`: a refusal caused by the guard's expression evaluating `NULL` says so, e.g. `"guard evaluated to NULL (unknown)"`, distinct from an ordinary `FALSE` refusal — and when the guard *does* declare a `message`, a NULL-caused refusal appends that same note to it, e.g. `"Amount must be positive (guard evaluated to NULL (unknown))"`, rather than the message alone reading identically to an ordinary `FALSE` refusal). Exactly like a refused plan's `"refusals"`, an additive top-level `"errors"` array — `[{code, element, detail}, …]` — carries **every** failing guard (`commit()` evaluates all binding guards in declaration order and collects every failure, `BSL-REQ-41`); the four top-level fields always mirror `errors[0]`, so a client reading only them keeps working unchanged. As of M4.1 an **invariant** failure (`code: "invariant_failed"`, element `"<Object>.<invariant name>"`, see "Aggregates, rollups, and invariants") rides this same array, one entry per failing object. `errors` is present only for those two — any other `422` (a type/bind problem, a unique or foreign-key violation, …) carries no `errors` array. A guard failure and an invariant failure never appear in one response: guards are a hard gate, so a refused guard means no write, no recompute, and therefore no object post-state to assert against.

**A tenant waiting for a metadata migration (`BSL-REQ-317`):** when a tenant's stored metadata no longer loads on this engine (it predates a grammar change), the engine still starts and every request to that tenant answers `503` with `error: "unavailable"`, `code: "metadata_upgrade_required"`, the first refusal's element and a `detail` naming the refusal. Only its metadata Git endpoints (`/git/<tenant>/…`: fetch, push and plan, open to its `admin` members), `GET /tenants/<tenant>/git/status` and `/git/receives` stay open. Push corrected metadata there with the usual plan approval; once the push applies, the tenant is served again without a restart. The operator's `GET /tenants` lists the reason under `needs_migration` (`null` for a tenant that loads). A push to such a tenant is planned against its stored metadata read through the upgrade view (plan advisory `previous_metadata_upgraded`), or refused with `previous_metadata_unreadable` when even that cannot read it.

**Name clashes (ADR-0068, `BSL-REQ-322`):** a push whose tenant tree and vendored packages would give one short name to two objects, relationships, named types or queries is refused `422` with `code: "ambiguous_name"`, the short name as `element`, and an additive top-level `"candidates"` array — one `{name, namespace, package?, version?, path?}` per element the name could mean (`name` is the full `namespace:short`; a tenant-authored candidate is `namespace: "core"`) — so a client need not parse `detail`. No other error carries `candidates`. A package that declares no namespace but exports a model element is refused `package_namespace_missing`; one whose namespace is malformed or reserved (`core`, `basel`) is refused `invalid_namespace`; two packages declaring one namespace are refused `namespace_conflict`.

**Rule owner (ADR-0071):** a refusal caused by a guard or invariant the tenant added to a package's object carries an additive `"owner": "tenant"`, at the top level and on its `errors[]` entry, naming who owns the rule; it appears only when the rule's owner differs from the object's. No other error carries `owner`.

## Metadata grammar and type table

Canonical metadata is a decomposed Git tree. `basel.yaml` declares `app` (the tenant's root app) and `metadata_format` (currently only `1`); `objects/<snake>.yaml`, `relationships/<snake>.yaml`, `functions/<snake>.yaml`, and `types/<snake>.yaml` each own one named construct; `additions/<namespace>/<snake>.yaml` adds to one package object (see Apps from packages); `wasm/<snake>.wasm` owns one WebAssembly module; and `ui/` owns the app's UI definition, object, page, and indexed asset metadata. `README.md` and `.gitignore` are ignored; every other path is refused. A YAML construct's authored `name` (or UI object/page identity) must snake-case to its filename. Basel bundles these files deterministically into the read-only compiled document returned by the metadata history endpoints. Objects and attributes retain authored casing in BQL and in JSON; physical names are derived by snake-casing (`Widget` → table `widget`; `closeDate` → column `close_date`; a `ref` attribute `account` → column `account_id`). Two declarations that snake-case to the same physical name are a validation error at resolution, never a collision at apply time (design doc §5.2).

| Basel type | Postgres | Notes |
|---|---|---|
| `text` | `text` | |
| `bool` | `boolean` | |
| `int` | `bigint` | |
| `decimal` | `numeric(18,4)` | |
| `uuid` | `uuid` | user data, always bare on the wire — not an engine-owned id |
| `Money` | `numeric(18,4)` | Currency deferred — see design doc §10 |
| `Date` | `date` | |
| `Timestamp` | `timestamptz` | written as RFC 3339 with an offset; always returned as RFC 3339 in UTC (`2026-09-18T00:30:53.214000Z`, always six fractional digits, so it sorts as text), which writes back unchanged |
| `Duration` | `interval` | fixed elapsed time; read and written as ISO-8601 (`PT3H12M`, `P1DT2H`) — always returned in canonical form |
| `DateRange` | `daterange` | |
| `Email`,`Phone`,`PersonName`,`Region` | `text` | CHECK constraints deferred |
| `enum` | `text` + `CHECK (col IN (...))` | Not a PG enum type — see below |
| `set` | `text[]` | |
| `ref` | `uuid` + `FOREIGN KEY` | `ON DELETE RESTRICT`; `TypeID` on the wire |

Every object gets `id uuid PRIMARY KEY DEFAULT uuidv7()` — that column type is storage only; `id` renders as a `TypeID` everywhere it's emitted (see "Ids: TypeID on the wire" above).

`decimal` and `Money` values cross the JSON boundary as **strings**, never JSON numbers — a JSON number is an `f64` and would silently lose precision. `int` crosses as a JSON number, `bool` as a JSON boolean, `set` as a JSON array of strings, and every other type as a string.

**`default:` (Task 3, DR-05).** An attribute may declare a literal creation default: `enum`/`text`-family values and `Date`/`Timestamp` as strings, `int` as an integer, `decimal` as a **quoted string** (the same precision rule above), `bool` as `true`/`false`, and `Money` as a quoted decimal amount (a fixed-currency attribute) or `{amount, currency}` (an open one) — validated against the attribute's own resolved type at metadata resolution, the identical gate a guard or rollup literal passes through. Refused (`invalid_default`) on a `ref` or `set` attribute (neither has a creation-time literal form), on a rollup-owned attribute (its field is computed from `value:`, never created), and on a relationship property (scoped to an object's own attributes in Task 3). Applied **exactly once**, on create, to a declared attribute the request omits **entirely** — an explicit `null` is not "absent" and is validated exactly as it would be with no default declared (required and missing, if the attribute is non-optional). Never re-applied on update, and never overwrites a value the request actually supplies. Identical through the generic, nested (the nested write path), and action-effect/body-generated endpoints, since all three seed a create's starting row from the same declared defaults. Reflection publishes it as `"default": <literal>` on the attribute — present only when declared, never `null`.

**`backfill:` (Task 4, DR-06).** A *second*, independent literal a migration uses to fill a required attribute's existing rows the moment it is first added to a table that already has data — Django's one-off `default:` prompt is the precedent (register decision M4). Same literal shapes, same validation gate, same refusal code shape as `default:` (`invalid_backfill` in place of `invalid_default`), refused the same way on a `ref`/`set` attribute, a rollup-owned attribute, and a relationship property. `default` and `backfill` are independent keys with **independent lifetimes**: declaring both on the same attribute is legal, and each can carry its own literal. `default` is read by the engine, every time a create omits the attribute, for as long as it stays declared. `backfill` is read **once**, by the planner, at the moment a required attribute is added to a table that already has rows — never again afterward, and never at create.

Adding a required attribute to a table that already has rows is refused (`required_column_on_nonempty_table`) unless the attribute declares `backfill:` — M1 metadata has no other value to fill existing rows with, and the refusal's own detail names the remedy: declare `backfill: <literal>` to fill existing rows, or make the attribute optional. With `backfill:` declared, the plan emits one `backfill_required_column` operation instead of refusing: `ADD COLUMN ... NULL`, then a row-fill step writing the literal into `<column>` wherever it is still NULL, then `ALTER COLUMN ... SET NOT NULL` (plus the column's `CHECK`, for an `enum` attribute), all in the one apply transaction. This always succeeds — `backfill`'s literal is validated against the attribute's resolved type at metadata resolution, the identical gate `default` and a guard/rollup literal pass through — so it is never a *flagged* operation the way a constraint added over unverified existing data can be.

A declared `backfill:` that is never actually read this way is reported, not silently dropped: the plan response's `"advisories"` array (see "Rule-selection advisories" below) carries a `backfill_unused` entry when the table turns out to be empty (an ordinary `ADD COLUMN NOT NULL` needs nothing to fill) or when the attribute already exists (it isn't newly added by this plan at all, so `backfill` was never going to be read either way).

**Named scalar types under `types/` (Stage 2 task 6, DR-09).** A `types/<snake_name>.yaml` entry (or, in the monolithic document form, a top-level `types:` map entry keyed by name) declares `{base, check}`: `base` is any scalar type in the table above, or `enum` (with its own `values:`) — never `Money`, `ref`, `set`, or another named type. `check` is a predicate over the single free name `value`, bound as `base`, restricted to comparisons/arithmetic/`AND`/`OR`/`NOT`/`IN` over `value` and literals — no function call is admitted at all (so a transaction-time one like `TODAY()`/`NOW()` refuses the same way any other disallowed construct does: `invalid_type_check`). Structural, not nominal (register decision M14, Postgres `DOMAIN` semantics): an attribute or param declared `{type: <Name>}` resolves to `<Name>`'s own `base` unchanged — a consumer that never looks past the base type sees an ordinary scalar. Compiles to a per-column `CHECK` named `{table}_{column}_{Name}_check`; tightening or otherwise changing a still-declared check plans as a same-name `DROP`+`ADD`, validated against existing rows through the ordinary constraint-addition path (loosening is allowed the same way); dropping the named type outright (reverting to a bare base type) drops that `CHECK` and is refused like any other constraint removal. A `default:`/`backfill:` literal on a named-type attribute is validated against the check too, not only the base type (`invalid_default`/`invalid_backfill` on a violation). A write that violates the check at the database refuses `check_violation`, naming the attribute and the type. Reflection publishes `"named_type": "<Name>"` beside an attribute's/param's ordinary `"type"`, and `/reflect/types`/`/reflect/types/{name}` (see "Endpoints" above) list every declared named type's own `base`/`check`.

**Application queries under `queries/` (Stage 4 task 1, DR-11).** A `queries/<snake_name>.yaml` entry (or, in the monolithic document form, a top-level `queries:` map entry keyed by name) declares the same `{from, select, where, parameters, order_by, page}` shape a UI-authored named query does — it reuses that resolver exactly, so a bad `select` path still refuses `invalid_ui_query` at `queries.{name}...`. It resolves against the complete model (every object, formula, lifecycle and relationship), served by name at `POST /tenants/{key}/queries/{name}` and listed at `/reflect/queries`/`/reflect/queries/{name}` (see "Endpoints" above). A UI object's or page's own `queries:` entry binds one by identity with `{use: <name>}` in place of an inline plan — the bound surface query compiles to a clone of the application plan under its own surface-local id, keeping the application query's `owner`/`name` — so it runs and pages identically whichever endpoint calls it. A UI-authored query name may not collide with a declared application query's name unless it is exactly a `{use:}` binding to it: `query_shadowed`. A `{use:}` naming no declared application query is `unknown_query`, positioned at the binding's own `.use` key. A UI-local inline query stays legal and reachable only from its own object's or page's surface — never from `/tenants/{key}/queries/{name}` or another surface, since its id is `object.{Object}.{name}`/`page.{page}.{name}`, never `query.{name}`.

**Reference selectors (Stage 4 task 2, DR-11).** An application query (`queries/<name>.yaml`) may declare `purpose: reference, target: <Object>` — `where` must then be present and admissible (see below) over `target`, `from` must equal `target`, and `select` is clamped to `[id]` or `[id, <one orderable, non-ref attribute>]`: identity and a label, nothing else. **`order_by` is clamped to that same `{id, label}` set** — a caller who may reference but not read a row must not be able to binary-search a hidden column by reordering the picker. A `ref` attribute names one as its own selector with `attributes.<attr>.select: <query name>`. A `select` naming no declared application query refuses `unknown_query` at `{Object}.{attr}.select`; one that names a query which is not `purpose: reference`, or whose `target` does not equal the attribute's own `to:`, or an `attributes.<attr>.select` on a non-`ref` attribute, refuses `invalid_ui_query` at that same element. `select:`/`unique: true` are object-only attribute keys: on a relationship property they refuse `not_implemented` at `relationships.{Relationship}.properties.{name}.select` and `invalid_constraint` at `.unique`. A purposed query declares no `parameters` at all (register ruling M17: a caller must reach the same verdict as every other caller for the same row) — `reference_query_inadmissible` at `queries.{name}.parameters` otherwise. Its `where` must be present and read only stored columns of `target`/`from` — a single-segment attribute, a rollup's own field, or a lifecycle's managed state, never a formula (which owns no column) or a relationship traversal/aggregate/quantifier/transaction-time function (the same admissibility gate an invariant's `assert` passes through) — refused under the same code, positioned at `queries.{name}.where`. Choosing (or replacing) a selector-governed field at commit time is a third, independent check (register ruling M5), run beside every other write-authority check: visibility decides which rows a *query* returns; ADR-0028's `reference` permission decides whether this actor may point at this row at all (refuses `not_found`, unchanged by this task); and this selector's `where`, evaluated against the chosen row's complete, unfiltered record inside the write transaction — never the caller's own visibility, so every caller reaches the identical verdict for the identical row — decides whether the model still calls it a legal choice *now*. A row that already held a selector-governed field before this write is never re-checked (deactivating a referenced record does not retroactively invalidate what already points at it): an attribute is checked exactly when this operation's own values name it with a non-null id. A failing choice refuses `reference_ineligible` at `{Object}.{attr}`. **Invoking a `purpose: reference` plan** needs no ordinary `read` on the target: a `reference`/`referenceAny` permit alone admits it, and discloses the plan's own id and label and nothing else. Holding `read` as well does not narrow that — the two grants compose (the readable fields are their union, the row filter the `OR` of their filters), so adding a permit never takes selector authority away; and holding `read` on every field does not widen it either, because the `{id, label}` clamp is structural. A delegated/capped session is refused outright rather than admitted through the delegator's own uncapped `reference` permit — a refusal pending recomposition (bead `basel-appmodel.2`), not a permanent shape. A page whose admission drew on a `reference` grant carries **no `permitted` block at all**, even when `include: ["permitted"]` asks for one: that block is checked under the caller's own read policy, where a row reached by reference alone would be stamped `actions: ["read"]` — a claim a reference selector is defined never to make. Reflection: `/reflect/queries`/`/reflect/queries/{name}` carry `"purpose": "list"|"reference"` and, for a reference selector, `"target": "<Object>"`; an attribute's own reflection always carries `"select": "<query name>"|null`, present whether or not the attribute declares one.

**Conditional uniqueness and interval exclusion (Stage 4 task 3, DR-12).** An object may declare `constraints:`, a name-keyed map of integrity rules the *database* owns rather than a read-then-write check the engine runs: `constraints: {one_active_enrollment: {unique: [student, program, academic_year, term], when: status != 'cancelled'}}` compiles to a partial unique index (`ux_<table>_<name>`), and `constraints: {no_double_booking: {exclude: {resource: room, interval: [starts_at, ends_at]}, when: status = 'confirmed'}}` compiles to a `gist` exclusion constraint (`ex_<table>_<name>`). Two writers who each see no conflicting row still cannot both commit: the index, not a read, is the arbiter. An entry declares exactly one of `unique:`/`exclude:`. A `unique:` tuple names declared attributes with one physical column each — a `ref` qualifies (one FK column), a `set` and an open `Money` do not. An `exclude:`'s `resource` is compared with `=`, and its `interval` names exactly two attributes of one shared `Date`/`Timestamp`/`int`/`decimal` type. **Intervals are half-open, `[start, end)`, and that is not configurable**: a booking that starts exactly when another ends does not overlap it. `when:` is optional — omitted, every row participates — and passes the same own-row admissibility gate an invariant's `assert` does: stored columns of this row only, no formula, no traversal, no aggregate, and **no transaction-time function** (a predicate that moved with the clock would silently change which rows the index covers with no write happening at all). `unique: true` on an attribute is sugar for a one-attribute entry named `{attr}_unique` with no `when`; declaring both refuses (`invalid_constraint` at `{Object}.attributes.{attr}.unique`) — a rule is declared once. Malformed declarations refuse `invalid_constraint` — including a `when` naming a construct a partial-index predicate cannot render: a function call, a `CASE`, or a money literal (Basel's money is an amount/currency column pair, which no single-column comparison names). Removing a `constraints:` entry, or re-declaring it under the other kind, **drops** the physical object Basel owns for it in the same plan; a `ux_`/`ex_`-named object that appears in no declaration, this version's or the last, stays drift and is never touched. Declaring a rule over rows that already break it refuses at plan *and* at apply, `constraint_apply_failed` at `{Object}.constraints.{name}`, with a violation count and a bounded `TypeID` sample; a write that conflicts refuses `constraint_violated` at that same element, naming the object and the rule and never the conflicting values. **Deployment requirement:** an `exclude:` entry's DDL runs `CREATE EXTENSION IF NOT EXISTS btree_gist` first — a scalar `resource WITH =` operand needs it, and the extension is database-scoped, so it cannot live in a per-tenant schema. It is idempotent and runs inside the apply transaction, but a deployment role without `CREATE EXTENSION` privilege refuses at apply with the raw Postgres error; install `btree_gist` once, as a superuser, before applying any document that declares `exclude:`. Reflection: an object carries `"constraints"` beside `"invariants"`, each entry `{kind, unique | {resource, interval}, when, compiled}` — `compiled` being the emitted `ux_…`/`ex_…` identifier, so a reader can find the physical object. **Cross-object exclusion (stage 5 task 7, register M25).** A partial exclusion constraint is an index on *one* table, so two different objects competing for the same resource cannot share an `exclude:` directly — model a shared object that holds the interval fields and the single `exclude`, and have both competing objects hold a `ref` to it instead of duplicating the fields onto each. `examples/education` works this pattern: `RoomReservation` carries `room`/`starts_at`/`ends_at`/`status` and `no_double_booking`; `Booking` and `Assessment` each hold only a `ref` to a reservation, and the refusal names the object's constraint, never either referencing object.

**Structured types and `list` params (Stage 3 task 4, DR-10b).** A `types/<snake_name>.yaml` entry may declare `fields:` instead of `base`/`check` — a structured type, e.g. `types/invoice_line_input.yaml`: `fields: {description: {type: text}, product: {type: ref, to: Product}, quantity: {type: PositiveQuantity}, unit_price: {type: Money}}`. A field's own type is a builtin, `ref`, `Money`, or a scalar named type — never another structured type (`invalid_type`) and never `list`. Legal only as a param's own bare type, or as a `list`'s `of:` — never an attribute's own type. `type: list, of: <Name or builtin>, min_items, max_items` declares a bounded list param, e.g. `params: {lines: {type: list, of: InvoiceLineInput, min_items: 1, max_items: 10}}`; an invocation's list value shorter/longer than the declared bounds refuses `list_bounds` at `params.{p}`. A structured param's/list item's value crosses the wire as a JSON object/array exactly matching the declared fields; each field/item is validated recursively, positioned `params.{p}.{field}` (a bare structured param) or `params.{p}[i].{field}` (a list item), including a named-typed field's own `check`. A body receives a structured/list param's value as its already-validated JSON, serialized to a string in the existing `field-value::text` wire slot (no widened WIT contract in v1). Reflection: a param's own `"type"` is `{type: "list", of, min_items, max_items}` for a list, or the structured type's own name for a bare structured param; `/reflect/types/{Name}` on a structured type shows `"kind": "struct"` and its `fields` (each shaped like a param).

**`reference_choice` (Stage 6, DR-19).** A `types/<snake_name>.yaml` entry may declare `reference_choice:` instead of `base`/`check` or `fields:` — a closed, tagged reference to exactly one of N objects, e.g. `types/note_subject.yaml`: `reference_choice: {customer: Customer, vendor: Vendor, campaign: {to: Campaign, select: live_campaigns}}`. An arm is `<arm_name>: <Object>` or `<arm_name>: {to: <Object>, select: <reference query>}`; arm names are `snake_case`, and `kind` and `id` are **reserved** (they are the wire value's own members, `name_collision`). Two arms of one type may not name the same target (`duplicate_arm_target`). Legal as an attribute's own type and nowhere else — not a param's, a `list`'s `of:`, a struct field's, a relationship property's, a formula's or a rollup's (`invalid_type`). Use it as `subject: {type: NoteSubject}`; `optional: true` admits no arm at all, and the default `required` means exactly one, enforced by a database `CHECK` over the arm columns. `on_delete:` may name arms, but `restrict` is the only legal value in v1 and it is also the default — anything else, or the key on a non-choice attribute, refuses `invalid_choice_lifecycle`. **Wire value:** one JSON object, `{"kind": "customer", "id": "customer_01j9…"}` — both members always, since there is no partial form: a half-chosen value is not representable, and writing one arm replaces whatever arm was there in the same statement. Reading adds `label` when the populated arm declares `select:`, from that query's own label projection. The key's presence is a function of the arm, never of the reader — but its **value** is a checked read of the arm's target: `null` unless the reader may read or reference that row, and `null` on a write echo always, since a `POST`/`PATCH` response is produced inside the write transaction where there is no reader to check. Read the label back with a `GET` or a query. **Paths:** `{attr}.kind` is the derived tag — computed from which arm is populated, never stored, **read-only**: writing `{attr}.kind` refuses `choice_tag_not_writable`, and any *other* dotted key on a write refuses `unknown_attribute`, since no such name exists to write — and `{attr}.<arm>` is an ordinary `ref` path that traverses: `subject.customer.owner.region` resolves, one segment deeper does not, because an arm costs two segments of depth. An arm name no declared arm matches refuses `unknown_choice_arm`, whether written as a value's `kind` or as a tag literal. **The whole value** is legal in exactly three positions — `IS [NOT] NULL`, `=`/`!=` against another path of the *same* nominal type, and a `set:` effect copying it whole — and nowhere else: there is no object literal in BQL or the expression grammar, so `subject = 'customer_01j9…'` refuses `choice_not_arm_qualified` and tells you to compare an arm instead (`subject.customer = UUID '…'`). Ordering refuses at two different gates: a bare choice in an ordering position is not one of the three positions above, so it refuses `choice_not_arm_qualified` before any operator is weighed; the tag refuses `choice_tag_not_a_value` — an arm set has no order — in `ORDER BY` and in a `<`/`>` comparison alike. **Reverse reads:** "everything attached to this Customer" is a filter on one arm column, `WHERE subject.customer = '<id>'`, and each arm gives its own such read. A choice is not a relationship, so a target has no writable inbound related list: attaching is a write to the **referring** row, and the target's own surface is a read-only query binding. A row that breaks the count `CHECK` refuses `reference_choice_violated` at `{Object}.{attr}`, naming neither the row nor any value. Reflection: `/reflect/types/{Name}` shows `"kind": "reference_choice"` with its arms, and the attribute carries one field with `pg_type: null` and a `reference_choice` block beside its `type` — the arms, their labels, selectors and `on_delete`, and the tag's own value set. Arm columns, FK names and the count-constraint name appear nowhere under `/reflect`; they are in the `/plan` DDL, like every other managed name.

**Declared action results (Stage 3 task 4, DR-10b).** An action or transition may declare `result:` — a map of field name to `{type, optional?, disclosure}`. `disclosure: created_identity` names the record the invocation itself created (via a declared `Effect::Create` reaching that object, or a `function:` — resolution refuses `invalid_result` if the action has neither); that field's own `type:` must be `ref` naming the created object. `disclosure: <Object>.<attr>` projects that attribute off the SAME created record, and `<Object>` must match some other field's own `created_identity` ref target in the same `result:` (`invalid_result` otherwise). A declared result is projected inside the write transaction, after every other integrity check and before settlement (ADR-0043): a projection failure rolls the whole write back. Under the caller's own readable fields (the same read program the request already pinned — ADR-0023), one of three outcomes follows per field: the caller can read the created record and the disclosed field both, and gets the full value; the caller can read the record but not that field, and the field is omitted; the caller cannot read the created record at all, and a `created_identity` field is still returned as `{id}` (an identity receipt) with every other field omitted. If no such record was actually created at invocation time (a function that proposed no matching create despite the resolve-time check), the field is omitted outright. The invocation response carries the projected result as a `"$result"` object beside the existing receipt/projection row's own fields, present only when the invoked action declares a `result:` at all — `$`-prefixed, never bare `"result"`, because an attribute may legally be named `result` (`valid_ident` allows it) but never `$result` (`$` is never a legal identifier character), so the key can never collide with a row's own field, structurally, with no resolution-time name check needed. Reflection's own per-action/transition `"result"` key (a sibling of `"params"`/`"function"` on the action's/transition's own descriptor, never mixed into a row) keeps its bare spelling — it is never keyed by attribute name, so it carries no collision risk: `null` when none is declared, else `{fields: [{name, type, optional, disclosure}, ...]}`.

## Subtypes and interfaces

An object may declare `extends: <Object>`, making it a member of that object's **subtype family** (design §2–§3): one immediate parent, one shared table, one `concrete_kind` column the database enforces. `abstract: true` on the root means the root itself holds no rows of its own kind — only its concrete descendants do. Every member's `id` — root and subtype alike — carries the **family root's** `TypeID` prefix, never its own table name: a row created as a concrete subtype reads back with the root's own prefix, and every reference to it, from any member's route, resolves the same identity.

**`$type` names the concrete kind, on the way in and on the way out.** Every response for a family member's row carries `$type`, the row's own concrete object name — reading a root-typed row through an ancestor's route still returns its true kind. Creating through a concrete route fixes that route's own kind; creating through an ancestor's route requires `$type` to say which concrete member the new row is (an ancestor route with no `$type` on a **concrete** root creates that root's own kind, exactly as any ordinary object create does). `$type` is never itself writable — a `PATCH` naming it is `kind_not_writable` — because changing a row's kind is a distinct, explicit operation (§10.3's `convert` endpoint), never a field edit.

**An abstract root refuses a generic create.** `POST` through an abstract root's own route with no `$type` (or with a `$type` naming something outside the family) is `abstract_object`, naming every admitted concrete kind so the caller knows what to ask for instead; the same route accepts a create the moment `$type` names one of them. Reflection states this up front rather than making a caller learn it by refusal: an abstract root's `entity.instantiable` is `false` and it reflects no `create_contract` at all.

**A permit on a family root reaches its descendants — exactly, by default.** `scope: descendants` on a permit widens it from the named object alone to every member of its family; without `scope:`, a permit is **exact**: it authorizes only the object it names, never a subtype and never an ancestor. Widening is always explicit, in the direction the author writes it.

**BQL reads a family root's rows across every member with `FROM <root>`,** projecting only what every admitted member shares — a column only a subtype declares is `unknown_attribute` from the root, exactly as it would be for any object that never declared it; query from the subtype itself to reach it. `TYPEOF()` is a nullary metadata function, legal only where a query, a rollup predicate, or a formula predicate range over a subtype-family source: it reads the row's own concrete kind as a string, comparable with `=`/`!=` against a kind name (no `IN`, no `IS [NOT] NULL`), and refuses `typeof_not_allowed_here` anywhere else — a guard, an invariant, a lock, or a source outside any family.

**An interface is a declared common surface, and nothing is an implementor by accident.** A `types/<snake>.yaml` entry may declare `interface:` — read-only `members:` (a type, an optionality, and a free-text `meaning` reflection carries verbatim), `actions:` (a param contract and a result type), and an optional `extends:` list of other interfaces, flattened once per origin. An object opts in explicitly, with `implements: {<Interface>: {members: {...}, actions: {...}}}`, mapping every interface member to **one one-segment path** on its own effective set (an attribute, a rollup, a formula or a `ref`) and every interface action to one of its actions. Matching field names map nothing: two objects that happen to spell a field `amount` are not implementors of each other's contract. Conformance is checked when the metadata resolves — every member mapped, types equal (a `ref` may narrow to a descendant of the member's target), a required member mapped to a non-nullable source (a formula is nullable by construction and so may satisfy only an optional member), and a mapped action adding no required param. A family member inherits its ancestors' implementations as **obligations**: a subtype may add implementations, never drop one.

**An interface has no entity set; its persistent references are the reflected closed choice.** There is no `FROM <Interface>`, no `/objects/<Interface>`, no cross-implementor list or cursor, and no generic invocation endpoint: to act on an interface, read `/reflect/types/<Interface>` — which carries the flattened `members` with their `meaning` and origin, the `actions`, the `implementations` map (each object's member and action mappings, with `origin` where an implementation is inherited), and an explicit `unsupported` list so a client never has to guess — then invoke the concrete object's own typed action route. An attribute **typed by** an interface is where an interface becomes storage: it reflects `kind: "reference_choice"` with one **exact-kind** arm per concrete implementor, generated in name order, and links back to its contract under `interface: {name, _href}`. That arm set is the stage-6 construct unchanged — one `{kind, id}` value or `null`, one atomic write across every arm column, per-arm authority and eligibility, `restrict` deletion, and one `basel.reference_choice_picker` for the whole field — so every choice rule already documented above applies verbatim. A family member's arm is its own exact kind: an ancestor's arm never admits a descendant's row, and a `ref to: <Root>` is the way to say "any member of the family". Publishing a new implementor adds its arm in the same metadata version; removing one refuses `removal_not_supported`, like every other structural removal.

## Next-generation UI (MUI-1)

Tenant UI source lives under repository root `ui/`: `app.yaml`, `objects/<snake>.yaml`, `pages/<snake>.yaml`, `assets.yaml`, and indexed files under `assets/`. An app has one UI definition (ADR-0072), `ui/app.yaml`, required when any `ui/` source exists (`missing_ui_definition`): `presentation` (the app's look, `<id>@<major>`; the app always uses that presentation's own default theme), `audience`, `home`, `navigation`, `theme`, `shell`, `queries`, `fonts`, locales, `workspace_context`, `record_pages`, `list_pages` and `defaults` (`object_list`/`related_list`: the component every generated object list, or generated related list on a record page, draws with instead of Basel's own `basel.data_table@1`/`basel.related_list@1` — a tenant component, a vendored one this app selected under `use:`, or a `basel.*` one; an unrecognized key refuses `unknown_default`, and a component that cannot fill the generated node refuses `default_component_incompatible`, naming what is missing: it must accept the attachment that node takes in its mode (`list.records`: `ui.content` in `list`; a related list: `stack` `children` in `view`), declare each binding Basel passes with the same kind (`object`, `fields`, `query`; `relationship`, `query`, and `fields` when columns are configured, which it must not require) and require no other, and require no config key without a default — Basel generates `{}`; an object list's filter bar and pagination go in its `toolbar` and `footer` slots, and only when it declares them — one that does not draws its own). `id:` and `default:` are not its keys, and the removed theme variant keys `allowed_themes`, `default_theme` and `allow_default_presentation` refuse `theme_variants_removed`. The app's title is `basel.yaml` `title:` (the tenant's key when absent), not a `ui/app.yaml` key. For one release a single legacy `ui/experiences/<name>.yaml` is read as the UI definition; two or more refuse `several_experiences`, and one beside `ui/app.yaml` refuses `ui_definition_twice`. `/<tenant>/apps/<app>` and `/<tenant>/` open the app; routes under `/apps` refuse `route_collision`. It is compiled with the semantic model into immutable, framework-neutral IR. UI-only applies execute zero operations (zero DDL) while still advancing the metadata version. The removed `/views` and `/reflect/layouts` HTTP contracts are not aliases for this API. An object or a page may author its own `route:` — a leading-slash, no-trailing-slash path, unique across every object and page (`route_collision` otherwise); an object's default is `/objects/<snake>`, a page's `/pages/<snake>`.

```yaml
# ui/objects/work_item.yaml
object: WorkItem
label: {default: Work item, fr-FR: Élément}
record_label: name
icon: target
compact: [amount, expected_close_date, stage, owner]
route: /work-items
queries:
  open_pipeline:
    from: WorkItem
    select: [id, name, amount]
    where: "stage = $stage"
    parameters:
      stage: {type: text, source: request}
    order_by: [{field: name, direction: asc}]
    page: {default_size: 50, max_size: 500, total: exact}
surfaces:
  list:
    base: generated
    overlays:
      - hide: {target: field.internal_note}
```

`icon:` names what the object is, as one of the console's icon names (`search`, `updown`, `chevron`, `plus`, `pencil`, `trash`, `external`, `arrow`, `check`, `close`, `building`, `person`, `target`, `receipt`, `package`, `box`, `link`, `layout`, `schema`, `reflection`, `states`, `lock`; `invalid_ui` at `ui.objects.<O>.icon` otherwise). It is served with the object (`/ui/objects`, the object's surfaces, reflection); the console shows it beside the record title and the object home heading and on the object's rail entry. How it looks is the presentation's: a presentation's `icons: {target: {color: "#fcb95b"}}` colours that icon name's tile (`#rrggbb`).

`compact:` lists the object's key fields, in order: 1 to 6 distinct own attributes, formulas or lifecycles, never a traversal (`unknown_ui_field` or `invalid_ui` at `ui.objects.<O>.compact[i]`). The record header shows them as highlights under the title (less the `record_label` field), board cards show those their query selects, a reference picker's options show the first two as a second line (the generated reference read selects them), and a related list with no configured `related_lists:` columns takes the related object's `compact:` fields as its default columns. Undeclared, each behaves as before.

**Row versions and optimistic concurrency.** Every persisted row's managed audit-event back-link is exposed only as a strong `ETag: "evt_..."` on create, GET-by-id, update, and action responses; named-query rows carry the same token as `version`. `PATCH`, row `DELETE`, and action invocation require that token in `If-Match`. Missing is `428 precondition_required`; malformed is `422 invalid_precondition`; stale is `412 stale_record` with `diff.current` and `diff.link`. Refresh and retry deliberately: there is no force or auto-merge token. Formula evaluation alone does not change a version; a materialized rollup write does. Successful create, update, and action results contain only fields the caller may read in the resulting state, including transitive formula readable fields. If the write makes its target unreadable, success returns only `{id}` plus the new ETag; it is a receipt, not a readable row. Replace result fields rather than merging them with a previously read record, and refresh through ordinary reads. Measured invocation rows use the same projection without committing the write.

**Assets.** References are explicit `standard:`, `component:`, or `tenant:` values and one namespace never shadows another. Tenant assets are declared in `ui/assets.yaml`, stored byte-exactly in canonical Git, and evaluated as descriptors containing metadata version, logical name, Git blob OID, SHA-256, kind/media/size, optional dimensions/default alt, and a digest-qualified URL. `GET /tenants/{key}/ui/assets/{logical_name}/{sha256}` returns exact bytes with a strong digest ETag and `public, max-age=31536000, immutable`; a mismatched pair is 404. Tenant kinds are PNG/JPEG/WebP/AVIF/SVG `image`, WOFF2 `font`, ES-module `script` (`.js`/`.mjs`, `text/javascript`, UTF-8, never beginning with `<`), and `stylesheet` (`.css`, `text/css`).

**Tenant components (ADR-0049).** An app ships its own React components as tenant metadata: a manifest per component under `ui/components/<name>.yaml` using the platform manifest's vocabulary (attachments, binding kinds, commands, capabilities, theme tokens stay platform-owned) with an `id` in a non-`basel` namespace (`<namespace>.<name>`), `entry: {asset: "tenant:<script asset>"}` naming the ES module that exports `components` keyed `<id>@<major>`, and optional `styles` naming stylesheet assets. Trees reference them exactly like platform components (`slate.issue_page@1`); evaluated surface and app documents carry a `components` map describing only the tenant components that tree uses (`source: tenant`, contract, `entry`/`styles` digest URLs), `/ui/components` lists both sources, and `/ui/components/{id}@{major}` serves either. Refusals: `reserved_component_namespace`, `invalid_component_id`, `duplicate_component`, `invalid_component_entry`, `invalid_component_styles`, plus the registry's own contract codes. Tenant components run in the host realm (trusted inline); admission is the metadata-write authority, not a sandbox. An app's `shell: {navigation: <node>}` hands the app shell's navigation slot to an authored `ui.navigation` node in place of the generated one (the brand row, tenant plate, account, sign-out and administration stay host-owned; `/ui/app` still lists the navigation groups). `shell: {brand: {name: <text>, mark: {asset: "tenant:<image asset>"}, caption: <text>}}` puts the app's own brand in that row in place of the console's Basel mark (`name` localizable, the app's title when absent, `mark` an optional tenant image asset, `caption` optional plain text); `/ui/app` serves it as `shell.brand` (`name`, `caption`, `mark` as the asset descriptor with its URL), `shell.brand` null when undeclared. A tree holding a component that declares the `record.create` command gets a `record.create` descriptor of kind `row_create` naming the node's `object` binding (or `null`), invoked as `record.create` with `{object, values}`. Every binding whose manifest kind is `query` — `comments`, `all`, not only one named `query` — is compiled to the same contract (`{id, name, compiled: true, from, select, parameters, order_by, page}`), so a component executes any of them at `/ui/queries/{id}`; one naming a query its surface does not declare refuses `invalid_binding` at `<surface>.<node>.bindings.<name>`. An object `view` surface declares `record.update` (kind `row_update`, naming the object) beside `record.delete` and its actions, so a custom view root PATCHes the current record with `command('record.update', {id, version, patch})`; `edit`/`create` keep their draft commands (`record.save`/`record.cancel`) and no `record.update`. The generated view's `record.actions` bar (`basel.action_bar@1`, where `record.delete` lives) exists on every object view, with `actions: []` when the object declares no lifecycle transition or action, so `{generated: record.actions}` always resolves. An app's `queries:` declares the app shell's own surface queries, authored exactly like an object's or a page's (`from`, `select`, `where`, `parameters`, `order_by`, `page`, or `{use: <application query>}`) with `source: request` parameters only (the shell has no record context); they plan as `app.<name>` with `owner: app`, `/ui/app` serves their contracts under `queries`, `/ui/queries/app.<name>` executes them, and the authored `shell.navigation` node binds them by local name through its query-kind bindings. The effective principal's id is not on `/ui/app` (a document cached by UI checksum for every reader): `GET /tenants/{key}/whoami` serves `effective_principal.id` and `acting_principal.id` per caller. A node in a page or list tree may declare `parameters: {<query param>: {source: "params.<url key>"}}` beside its `bindings`, naming a parameter of a query the node binds and the page request parameter (`ShellState.params`) the host fills it from; the evaluated node serves it as written, and it refuses `invalid_binding` (a record surface, a name no bound query declares, a `source` that is not `params.<key>`) or `unknown_key` (any key beside `source`) at `<surface>.<node>.parameters.<name>`.

**Presentation release batches (PCD §6).** Use `{selections: [{component, entry, styles, declaration}], reason, expected_ui_checksum, presentation_digest, rollback_of?}` at the same activation endpoint and tenant CAS. Each declaration carries API 1.0, a semantic fingerprint and the admitted baseline from the component endpoint's `release_contract`. Missing/breaking presentation declarations refuse. All selections commit together with one audit event; a rollback reselects the exact retained batch. Expected UI/presentation mismatch is `412 stale_presentation_activation`. Legacy single-component bodies remain supported for legacy components. Isolated artifact activation refuses; use metadata assets. Mounted editors retain their snapshot; ordinary navigation or explicit update prepares scoped CSS/fonts and swaps only when ready.
**Independent UI releases (ADR-0050).** A compatible implementation of a tenant component is released without a metadata apply. `POST /tenants/{key}/ui/artifacts` stores raw `text/javascript` (`script`) or `text/css` (`stylesheet`) bytes as an immutable, content-addressed artifact (SHA-256 identity, idempotent on the same bytes, 4 MiB cap, the same signature check as `ui/assets/`; never in tenant Git) served from `GET /tenants/{key}/ui/artifacts/{sha256}` with the asset endpoint's immutable headers. `POST /tenants/{key}/ui/activations` appends to a per-tenant append-only activation log: `{component: "<id>@<major>", entry: <sha256> (or null to clear), styles: [<sha256>], reason, rollback_of?}` with `If-Match: "<activation_revision>"` — compare-and-set, `412 stale_activation` naming the current revision, `428` without the header, `422` for an unknown component/artifact, a kind that does not fit its slot, an empty reason, or a `rollback_of` that is not a prior revision of that component. Rollback is a new activation naming a retained artifact; an artifact any activation references is never deleted. `GET /tenants/{key}/ui/activations` serves the state (`metadata.read`); the write paths need the tenant action `ui.release`, a separate grant from `metadata.push` that `role_admin` holds. `activation_revision` (0 before any activation) rides beside `ui_checksum` in `/ui`, `/ui/app`, every surface, the catalog and each manifest, and in every metadata ETag — `ui_checksum` itself never changes on a release, so named-query `If-Match` preconditions are unaffected. A surface's `components[key].entry`/`styles` point at the activated artifacts when a selection is live for a key the current metadata version declares (`activation: {source: activation, revision}`), else at the manifest's metadata assets (`source: metadata`), else nowhere (`source: none`; a manifest may omit `entry` entirely and be supplied by activation). An apply that changes a contract major retires the old key's selection (`effective: false`, reported, not resolved) and the new key starts on its metadata asset; restoring the old contract restores its selection. The engine reconstructs every selection from the log at startup. A dev session's browser-local preview override still beats an activation.

**Isolated pages (ADR-0051).** A tenant component manifest may say `execution: isolated` (default `trusted`): the component then renders as a whole page in a `sandbox="allow-scripts"` iframe on a separate origin, and it is admitted only for `ui.root` in `page`/`list` modes with no `slots`, no `draft` and none of `draft.read`, `draft.write`, `overlay`, `asset.read` (refusal `invalid_component_execution`); surface documents carry `components[key].execution`. The console mints the frame with `POST /tenants/{key}/ui/frames/{id}@{major}` (`metadata.read`, an `Origin` header required) and gets `frame.url` on the isolated origin (`BASEL_ISOLATED_ORIGIN`; in dev mode the engine's own) with a sealed two-minute ticket; `409 isolation_unavailable` when no isolated origin is established or it equals the caller's, and the page then refuses rather than running trusted. `GET` of that URL reads no session — the ticket is the authorization (`404` without one, `412 stale_ui` after a redeploy) — and serves the frame document under `default-src 'none'; script-src <entry>; style-src <stylesheets>; style-src-attr 'unsafe-inline'; img-src/font-src <the frame asset endpoint>; connect-src 'none'; frame-ancestors <the console>; base-uri 'none'; form-action 'none'; sandbox allow-scripts`. Its assets come from `GET /tenants/{key}/ui/frames/{id}@{major}/assets/{logical_name}/{sha256}` (no session, `Access-Control-Allow-Origin: *`; only that component's entry and stylesheets and the tenant's image/font assets). Inside the frame the page talks to the console over the `isolated/1` `postMessage` bridge (`@basel/ui-sdk/isolated`), which grants its node's named queries, its manifest's commands, object/page navigation, notifications and the theme/context snapshots — never the draft, an overlay, a fetch or a credential.

A surface is a typed node tree. Each node has a stable `id`, a `<component>@<contract-major>`, one attachment, closed-schema config, typed bindings, optional compiled conditions, and manifest-declared named slots. Generated list/view/create/edit trees are total. Ordered overlays support `configure`, `hide`, `append`, `prepend`, `replace`, and `move`; missing targets, conflicts, illegal placement, duplicate IDs, and limit breaches refuse apply. Conditions are UX-only and may read own stored fields/lifecycle state plus deterministic scalar functions; they never grant write authority.

`GET /tenants/{key}/ui` is the rendering root; `/reflect/ui` is the authoring/topology root. Evaluated metadata returns normalized IR, never raw YAML, and uses strong locale-and-surface ETags with `If-None-Match`/`304`; the tags also move with the engine build, so an engine upgrade never answers `304` with an older body. Named-query POSTs require `If-Match: "<ui_checksum>"`; missing is `428 precondition_required`, stale is `412 stale_ui`, and query/data responses are `no-store`. Runtime actor or policy context remains the declared M6 `501 {"status":"not_implemented","milestone":"M6"}` seam.

**Published limits.**

- Component-tree depth: 24
- Nodes per surface: 500
- Slots per component manifest: 32
- Overlay operations per mode: 200
- Named queries per object/page: 32
- Selected paths per query: 64
- Query parameters: 32
- Default/max cursor page: 50 / 500
- UI condition nodes: 128
- Localized variants per text: 20

**Installed standard components.**

- `basel.action_bar@1` — Action Bar
- `basel.activity@1` — Activity
- `basel.app_shell@1` — App Shell
- `basel.card@1` — Card
- `basel.chart@1` — Chart
- `basel.command_palette@1` — Command Palette
- `basel.conflict_state@1` — Conflict State
- `basel.data_table@1` — Data Table
- `basel.empty_state@1` — Empty State
- `basel.error_state@1` — Error State
- `basel.field@1` — Field
- `basel.filter_bar@1` — Filter Bar
- `basel.grid@1` — Grid
- `basel.heading@1` — Heading
- `basel.image@1` — Image
- `basel.kanban@1` — Kanban
- `basel.kind_chooser@1` — Kind Chooser
- `basel.lifecycle@1` — Lifecycle
- `basel.loading_state@1` — Loading State
- `basel.markdown@1` — Markdown
- `basel.markdown_field@1` — Markdown Field
- `basel.metric@1` — Metric
- `basel.metric_strip@1` — Metric Strip
- `basel.navigation@1` — Navigation
- `basel.navigation_group@1` — Navigation Group
- `basel.page@1` — Page
- `basel.pagination@1` — Pagination
- `basel.record_fields@1` — Record Fields
- `basel.record_form@1` — Record Form
- `basel.record_header@1` — Record Header
- `basel.reference_choice_picker@1` — Reference Choice Picker
- `basel.reference_picker@1` — Reference Picker
- `basel.related_list@1` — Related List
- `basel.relationship_editor@1` — Relationship Editor
- `basel.relationship_field@1` — Relationship Field
- `basel.section@1` — Section
- `basel.split@1` — Split
- `basel.stack@1` — Stack
- `basel.tab@1` — Tab
- `basel.tabs@1` — Tabs
- `basel.unavailable_state@1` — Unavailable State
- `basel.validation_summary@1` — Validation Summary

**Link-only traversal.** Start at `/`, choose a tenant, follow its `/reflect` root's `ui._href`, then an object/page `_href`. Follow an `evaluated_href` to obtain the normalized tree and `ui_checksum`; follow a query contract's `_href` and POST its typed context/parameters/page object with that checksum in `If-Match`. Commands in the evaluated surface identify the existing typed object/action endpoints; no undocumented UI URL or raw BQL construction is required. Runtime errors add `stale_ui`, `invalid_cursor`, `invalid_query_parameter`, and `ui_registry_mismatch` to the ordinary Basel error envelope.

**Related-list edit mode (Stage 3 task 5; DR-18).** In `create`/`edit` mode, `basel.related_list` binds its relationship writably to the shared draft and renders existing members and pending rows as one editable table: adding a line stages a `create` entry, editing a cell on an existing member stages an `update` entry (`{"id": ..., "values": {...}}`), and removing a row stages a `delete` entry (dropping any staged `update` for the same id) — there is no independent save, and the record's own `submit()` remains the only serializer (MUI1D §6.4).

**Related records on generated surfaces (BASEL-244, BASEL-246).** A traversal whose declaring end allows at most one record (`cardinality: {max: 1}` on this object's own end) is a field, not a related list: the generated record page shows it as `field.<traversal>`, a `basel.relationship_field` among the fields (the linked record by its label, linking to it), and places no related list for it; the generated `create`/`edit` forms carry the same node as a picker (search, choose, clear) when the mode may write it (`on_create` grants `link`; `on_update` grants `link` or `unlink`). Saving the form creates, replaces or removes the link in the record's own write — `{"<traversal>": [<id>]}` on create, `{"<traversal>": {"link": [...], "unlink": [...]}}` on update. A to-one relationship that declares its own properties keeps a related list. Generated forms carry no other relationship editor: linked records are added, edited and removed on the record page's related lists, whose rows offer Edit (the related record, in its own editor) and Remove (the link only, never the record).

## Relationships

A `relationships/<snake>.yaml` file declares one named, typed, multi-instance link between two objects, optionally carrying its own properties (design doc §5.5 `BSL-REQ-33`/`34`; ADR-0006):

```yaml
name: Employment
from: {object: Person, name: employments}
to:   {object: Company, name: staff}
unique: false                          # default; true adds a DB-enforced unique(from, to)
properties:
  role:  {type: text}
  start: {type: Date}
  end:   {type: Date, optional: true}
```

**`from`/`to`** name the endpoint objects and each side's traversal name — the identifier that side's own namespace reserves for reaching the other endpoint (`person->employments`, `company->staff` — notation only: the `->` traversal operator itself is refused in BQL/guard `when` until M4, design doc §6.4); a traversal name colliding with an attribute or another relationship's traversal on the same endpoint object is a resolution error, just like any other name collision. **`properties`** reuses the attribute vocabulary verbatim. M2's only declared shape is many-to-many, multi-instance: the same pair of endpoint rows may be joined by any number of links unless **`unique`** is declared `true`, which compiles to a database-enforced `unique(from_id, to_id)` — the only race-proof form of "at most one link between this pair"; any violation surfaces as `422 unique_violation`. `unique` defaults to `false`. `ref` remains the sugar construct for the anonymous, property-less many-to-one — declaring a relationship is only needed when a link needs its own identity, properties, multiplicity, or `unique` enforcement.

**Endpoints are immutable (`BSL-REQ-50`).** A `PATCH` on a link may change its own properties only; a body naming `from` or `to` is refused — re-pointing a link at a different endpoint is a different link, so the only path is delete-and-recreate, which keeps identity (and, come M4, history) honest.

**Deleting an endpoint row while links reference it is refused by default, not cascaded (`RESTRICT`, `BSL-REQ-38`)** — unless that end declares `on_delete` otherwise (see below): with no `on_delete` declared, an object never silently loses a link as a side effect of deleting the row it points at — delete the links first.

**Links are rows, addressable through the exact same surfaces an object's own rows are (`BSL-REQ-35`/`36`).** `POST`/`GET`/`PATCH`/`DELETE /tenants/{key}/objects/{Name}[/{id}]` and `FROM {Name}` in BQL both work identically for a relationship name — `from`/`to` behave as required, immutable `ref`-typed fields, and links carry `TypeID`s under the exact same discipline as any object row (see "Ids: TypeID on the wire" above), prefixed by the relationship's own snake-cased table name (`employment_01k…`).

`GET /tenants/{key}/reflect/relationships` and `.../relationships/{Name}` are implemented reflect scopes (see "Using /reflect" below), never a `501` stub: every declared relationship's endpoints, traversal names, `unique` flag, and properties — in the same full vocabulary `/reflect/objects/{Object}` uses — are reflected explicitly, never left to be inferred by scanning for a `ref`.

**`cardinality` (design doc §4.1; ADR-0010; `BSL-REQ-89`/`90`; `BSL-REQ-229`; register M22).** Either end may declare `cardinality: {max: 1, min: 1}` — `max: 1` (each row on that end participates in **at most** one link; a to-one traversal, the fact aggregates and nested create/update's `422 cardinality_exceeded` both consume, and — register M20 — the fact that makes the traversal a **readable dotted path hop** from the declaring end's own object, in queries, guards, invariants, locks and formulas alike, rendered as a join through the junction; a to-many end, or a second traversal hop in one path, refuses `bql_compile_error`) and `min: 1` (each row on that end participates in **at least** one link — required participation, a relationship-level fact with no lifecycle and no authority attached: it implies nothing about `on_delete`/`reparent`/`edits`). `max` is optional and, when declared, only `1` is legal in v1; `min` defaults to `0` (today's behavior — no requirement) and only `0`/`1` are legal. **`min` and `max` are independent, in both directions**: `min: 1` is legal on a member end alongside `max: 1`, e.g. `InvoiceLine.invoice` for "every line belongs to an invoice", and equally legal alone on a to-many parent end, e.g. `BudgetaryPosition.accounts` for "every position holds at least one account" — the same two refusals (`participation_required`, `participation_apply_failed`) apply at whichever end declares it.

Checked in commit phase 2, right after invariants, against the record after the write, over every row of the `min: 1` end's object that a proposal created or whose link in that relationship it unlinked or re-pointed: a parent, its child, and the link between them may be created in one proposal; a `create` of a child with no link refuses; an orphaning `unlink` refuses. Refusal `422 participation_required` names the end (`<relationship>.<end>`) and the offending row's `TypeID`. A row this same proposal **deleted** is exempt (`BSL-REQ-102`'s delete rule: a deleted row has no final state to check) — a nested `delete` (unlink then delete the member in one proposal) never orphan-refuses. Declaring `min: 1` over **existing** rows that already have no link refuses at plan and apply too, `422 participation_apply_failed`, naming a violation count and a bounded `TypeID` sample — the identical admission rule an invariant follows (`BSL-REQ-102`), reused rather than reinvented. `max: 1` has no scan of its own: it compiles to a database `UNIQUE(<end column>)`, so a violation surfaces as a constraint failure at apply, never this scan. Reflection (`/reflect/relationships/{Name}`) always renders both `min` and `max` together when `cardinality` is declared at all, never one without the other.

**`on_delete`/`reparent` (Stage 3 task 1; DR-03, `BSL-REQ-230`) — lifecycle rules, orthogonal to `cardinality`/`on_create`/`on_update` (ADR-0010; ADR-0041): declaring one implies nothing about another, and resolution refuses a placement that would make the key meaningless.** `on_delete` is declared on a parent end (the one whose *opposite* end declares `cardinality: {max: 1}`) and says what happens to its members when a parent row is deleted: `restrict` (the default — today's `RESTRICT` behavior, needs no declaration), `delete_members` (expand the delete into an `unlink` then a `delete` per current member link, ahead of the parent's own delete, all in one proposal and one audit event — a member delete runs that member's own guards, validators, and permits, exactly as an authored delete would, ADR-0018), or `detach` (unlink every member link and keep the members — refused at resolution when the opposite end also declares `min: 1`, since detaching would then structurally orphan it).

`delete_members`'s expansion is capped by the same 128-operation proposal budget every other proposal is (`BSL-REQ-81`): a delete whose expansion would exceed it refuses `422 delete_members_over_cap` naming the member count and the cap, and nothing is written. A member also holding a *required* (`min: 1`) link in some OTHER relationship the expansion does not touch refuses `422 delete_blocked_by_participation` rather than orphaning that requirement or cascading further; a member still referenced through any other plain relationship hits that relationship's own `RESTRICT` exactly as any other delete would.

`reparent` is declared on a member end (one whose *own* cardinality declares `{max: 1}`) and says whether a row on that end may move from one parent to another: `allow` (the default — nothing has ever refused this) or `refuse` (a proposal whose record after the write moves an existing member — an `unlink` of its current link together with a `link` to a different parent, in the same proposal, through any endpoint: a nested `unlink` + `link`, a raw `unlink` + `link` op in a raw client proposal, or an action body — refuses `422 reparent_refused`, naming the member and both parents). Creating a member with its first parent, and deleting a member outright, are not reparenting and never refuse here.

Reflection (`/reflect/relationships/{Name}`) always renders both `on_delete` and `reparent` on every end, with their defaults, whether declared or not — the same "reflection is total" discipline `cardinality`'s own always-rendered `min` follows.

**`edits: {when, record: after, message}` (Stage 3 task 2; DR-04b, `BSL-REQ-232`) — a business restriction, independent of actor permission, on the parent end** (the same placement rule `on_delete`'s non-`restrict` values use: the opposite end must declare `cardinality: {max: 1}`). `when` is a predicate bound against the parent's own object under `update` (`old.` legal); `record` is always `after` in v1 (any other value refuses `422 invalid_relationship`; the old `image: final` refuses naming it) — the member check reads the parent record *after* the whole write applies. While `when` is false on the parent record after the write: no member `create`/`update`/`delete`, no `link`/`unlink` of this relationship, and no change to the parent's own attributes commits. A no-op parent update (nothing actually changes) and posting an unchanged document both still pass — the null-safe no-op exemption stage 2's `locks:` already relies on. Editing a member and posting the parent in the *same* proposal refuses too: the member check reads the record after the write (the posted one).

Two refusals, not one. The member/link half is a phase-2 check (right after `reparent`'s own): a member `create`/`update`/`delete`, or a `link`/`unlink` of this relationship, refuses `422 edit_locked` at the offending operation's own authored position, with the end's `message` or a default naming the relationship and the parent. The parent's own `update`/`delete` is governed by an ordinary synthesized guard instead (`origin: {kind: "edits", relationship, end}` in `/reflect/objects/{Object}`'s `guards`): it refuses the usual `422 guard_failed`, with the same message. A member guard *can* now read its parent — a `max: 1` end is a readable path hop (register M20), so `budget.state = 'draft'` binds on the member, and it reads the **record after the write**: the parent this proposal links or re-links the member to, not the one the junction still holds, exactly as a `ref` hop reads the applied column rather than the stored one. But `edits` stays the declared way to express "members may only change while the parent is in state X": it is checked once per touched parent over the record after the write, covers `link`/`unlink` and nested `create`/`delete` (which no member guard sees at all), and synthesizes the parent's own guard from the same sentence. `edits.when` binds against the **parent** object, so it is `state = 'draft'`, never `budget.state = 'draft'`.

**`revision: true` (Stage 3 task 3; DR-04c, `BSL-REQ-233` as amended, register ruling M15) — the parent's `ETag` is the document version, on the same placement rule `edits`/`on_delete`'s non-`restrict` values use** (the opposite end must declare `cardinality: {max: 1}`). No new column, no new header: every committed change to this end's members' attributes, or to the relationship's own membership (a member `create`/`delete`, or a `link`/`unlink` of this relationship), also stamps this end's own row's existing `__basel_event_id` with the transaction's event id, once per parent per proposal — after the write and any rollup recompute, so a change that also moves a rollup (e.g. `total`) never double-stamps: both write the identical event id. A description-only line edit that touches no rollup still advances the invoice's own version, and so does a total-neutral line deletion a derived "max over document rows" version would miss. `If-Match` on the parent — a `PATCH`, an action invocation, or `submit_proposal` naming the parent as root — already covers it: an approver holding a stale parent version is refused `412 stale_record` by a child edit exactly as by a change to the parent's own attributes, with no refusal code and no wire surface of its own (ADR-0042). `false` when undeclared — no relationship stamps a parent's row version by default, and every relationship declared before this task keeps behaving exactly as it always did.

**`shape` (Stage 6 task 8; register M33) is reflected, never authored** — `document`, `association`, or `plain`, derived purely from the ends above: `document` means a `max: 1` member end whose parent declares `delete_members`, `edits:`, or `revision:`; `association` means neither end requires participation and neither declares any lifecycle key; `plain` is everything else.

## Nested create and update

**M4.5.** A declared relationship's traversal name (see "Relationships" above) is a legal field in the generic `POST`/`PATCH /tenants/{key}/objects/{Object}[/{id}]` body — no new endpoints, the same two endpoints already documented under "Endpoints". What a field may do through that traversal is declared **per end**, under the relationship's own metadata, and defaults to nothing:

```yaml
relationships:
  registration_event:
    from: {object: Registration, name: registration}
    to:   {object: TicketEvent, name: registrations}
    # declared per end, attached to each end's own declaration:
    #   registration end: on_create: {link: true}   # or create: true
    #   event end:        on_create: {}             # the default — untouched
```

**Vocabulary.** `link` (attach an existing record), `create` (create a related record inline), and `unlink` (detach on update) are legal under `on_create:` and `on_update:` on either end independently. `update` (edit an existing member through the parent) and `delete` (remove an existing member through the parent) are legal **only under `on_update:`** — a create has no existing members, so both are `unknown_key` under `on_create:` (DR-04a, `BSL-REQ-231`). Every flag **defaults false** — an undeclared relationship behaves exactly as before M4.5. Permissions come from the **declaring (near) end** — the end whose own object is the one being written and whose traversal name is the field — never the far end's declaration: a registration's own `on_create` governs `Registration`'s create body, not `TicketEvent`'s.

**On create — plain values**, since there is nothing to diff against: a **`TypeID` string** links an existing record (needs `link: true`); an **inline object** creates a related record (needs `create: true`), recursively, subject to the same rules and a depth cap; an **array** of either, for a to-many end; a link with declared properties uses `{"id": ..., "properties": {...}}` in place of the bare `TypeID`; omission and `null` mean "no links" and are never errors by themselves — invariants check the final state:

```json
POST /objects/BudgetaryPosition
{"name": "Salaries", "accounts": ["account_01h2...", "account_01h3..."]}
```

**On update — an explicit envelope**; there is no implicit array-diff:

```json
PATCH /objects/BudgetaryPosition/{id}
{ "accounts": {
    "link":   ["account_01h4..."],
    "create": [{ "name": "New account", ... }],
    "update": [{ "id": "account_01h5...", "values": { "name": "Renamed" } }],
    "unlink": ["<link TypeID>", "<link TypeID>"],
    "delete": ["account_01h6..."]
} }
```

`link` requires `link: true`, `create` requires `create: true`, `unlink` requires `unlink: true` and addresses **link `TypeID`s** (see "Ids: TypeID on the wire" above; link ids are visible in reads and reflection) — never the far record's id, since more than one link may join the same pair. The pre-rename verbs `connect` and `disconnect` are refused `unknown_key` naming their replacement. Omitting an envelope key leaves that membership untouched; a body that isn't this object — a bare array, a bare `TypeID`, `null` — is refused `validation_error` naming the envelope rather than guessed at. There is no "replace all" verb: replacement is an explicit `unlink` + `link`.

**`update` and `delete`** (DR-04a, `BSL-REQ-231`) edit or remove a record already a member of the relationship, legal only under `on_update:` — a create has no existing members, so both are `unknown_key` under `on_create:`. `update` requires `update: true` and addresses the member by its own **record `TypeID`**, alongside a `values` object validated exactly as a direct `PATCH` on that record would be (own guards, validators, permits — nothing inherited from the parent), at `<field>.update[i].values.<name>`. `delete` requires `delete: true`, addresses the member by a bare record `TypeID`, and expands to `unlink` (the link) then `delete` (the record) in the same proposal — the member's own `delete` guards run, and if it still participates in another relationship under `restrict`, the whole proposal refuses (`BSL-REQ-38`), exactly as a direct `DELETE` on it would. Either verb naming a record that is not currently linked through *this* relationship refuses `422 not_a_member` at the record's own position (`<field>.update[i].id` / `<field>.delete[i]`); the same record named under two verbs in one envelope — in either order — refuses `422 conflicting_operations` at the second mention. Emission order is fixed regardless of the envelope's own key order: `link`/`create` first, then `update`, then `unlink`, then `delete` — so a `delete` never races an `update` targeting the same member.

**Refusals**, all at the authoring position (an error `element` like `accounts[1]` or `accounts.link[0]`, per "Error shape" above — never a bare top-level code): a field naming a relationship without the permission it needs → `422 relationship_not_permitted`, naming which of `link`/`create`/`unlink`/`update`/`delete` is missing and on which block (`on_create`/`on_update`); the same far record named twice in one field's `link`/`create` → `422 duplicate_link`, citing the position it first appeared; the same record named under `update` and/or `delete` more than once → `422 conflicting_operations`, citing the position it first appeared; `update`/`delete` naming a record that is not a member of this relationship → `422 not_a_member`; a to-one declaring end (`cardinality: {max: 1}`) given a second element, or a new link while the record already holds its one (to replace it, `unlink` the current link and `link` the new record in the same envelope) → `422 cardinality_exceeded` at the field. Basel expands the whole body into one proposal and validates the final state in the same transaction — see "Aggregates, rollups, and invariants" for how a required-membership invariant checks what nested create just built.

**Limits (v1 contract numbers; changing them is a spec amendment):**

| Limit | Value |
|---|---|
| Operations per proposal (after expansion) | 128 |
| Nested-create depth | 4 levels |
| Effect operations per action invocation (before cascade) | 32 — see "Actions" below |

Exceeding a cap refuses `422 limit_exceeded` naming the cap, the same budget vocabulary the BQL/expression/aggregate limits above already use.

**Reflection.** Each object's own `/tenants/{key}/reflect/objects/{Object}` scope carries `create_contract`/`update_contract`: which of that object's relationship fields generic create/update accepts, keyed by traversal name, plus `_href` into the owning relationship's own reflection scope (a field appears only when at least one flag is true for that verb). `create_contract` entries name three flags — `link`/`create`/`unlink` — since `update`/`delete` are structurally never true under `on_create`; `update_contract` entries name all **five** — `link`/`create`/`unlink`/`update`/`delete` (DR-04a, `BSL-REQ-231`). A client discovers the nested shape the way it discovers everything else — this document explains the general mechanism; reflection documents each tenant's specifics.

**The honest boundary.** Nested create/update is a bounded, explicitly permitted graph write — per-end permissions plus the depth and count limits above, nothing more. It does not scope a write to "your own" rows (an authority question ADR-0010 reserves to M6) and does not define an aggregate/ownership boundary. When a setup flow needs reach the permissions don't grant, the answer is a named action (see "Actions" below), not wider permissions.

## Guards

A `guards` key, declared under an object *or* a relationship, is a named list of enforced write conditions (design doc §5.6 `BSL-REQ-40`):

```yaml
objects:
  Widget:
    attributes:
      amount: {type: Money}
    guards:
      - name: positive_amount
        on: [create, update]              # non-empty subset of create|update|delete
        when: "amount > 0"
        message: "Amount must be positive"  # optional; becomes the failure detail
```

**`name`** is unique per object or relationship and is what the wire and reflection cite. **`on`** is a non-empty subset of `create`/`update`/`delete`; an empty list, or a token that isn't one of those three, is refused `invalid_guard`. **`when`** is an expression in the exact same grammar BQL's `WHERE` uses (see "BQL" below) — `amount > 0 AND stage != 'closed'` is legal `when` syntax — compiled and bound at metadata *resolution*, never at write time: an unknown attribute, an illegal operator, or a binding-rule violation is a metadata refusal an agent sees while authoring, not a runtime surprise (`BSL-REQ-39`). **`message`**, when present, becomes the failure `detail` a refused write reports; omitted, the engine derives one.

A relationship's guard `when` binds against its link view — `from`/`to`/`properties` — exactly like an object's own attributes (see "Relationships" above): `when: "role != ''"` on an `Employment` relationship's `role` property, or `when: "to.tier = 'gold'"` traversing its `to` endpoint, both resolve the same way a `ref`-typed attribute does on an object.

**Binding by verb (design doc §6.4, `BSL-REQ-39`).** A guard checks the material of the write it's evaluated against — the proposed row is always the complete record after the write (the stored record with the patch applied), never the partial patch — and a bare path and an `old.`-prefixed path each bind differently depending which verb the guard is being checked for:

| Verb | bare path binds to | `old.<attr>` |
|---|---|---|
| `create` | the proposed row | refused — no stored row exists yet |
| `update` | the proposed row (the record after the write) | the stored value (the record before the write) — own attributes only, no further `.` traversal |
| `delete` | the stored row | refused — redundant; the bare row already is the stored row |

A guard bound to more than one verb (`on: [create, update]`) must compile under **every** verb it names — `old.` in a guard whose `on` includes `create` is refused at resolution, naming `create` specifically, even though the same `when` would bind legally under `update` alone. Traversal from a bare or `old.`-rooted path is to-one-hop only, following `ref` values on the row the guard checks, depth-capped exactly like BQL (`BSL-REQ-43`); `old.` never traverses past the row's own stored attributes. Because `old.` is a binding prefix in a context-blind grammar, `old` joins `id` as a reserved authored name: an attribute or relationship traversal name that snake-cases to `old` is refused at resolution.

**Enforcement is live in `commit()`** (design doc §7; `BSL-REQ-41`/`47`/`48`/`49`), not a future milestone: every write runs `commit()`'s single endpoint, and `commit()` runs, in order, field/type validation (§5.2's type table), then — for `update`/`delete` only, `create` has no stored row to lock — a `SELECT … FOR UPDATE` re-read of the target row, then **every** guard bound to the write's verb (`on`), evaluated in declaration order and **collected**, not stopped at the first refusal (`BSL-REQ-42`). A guard permits the write on `TRUE` only — `FALSE` **and** `NULL` both refuse, the deliberate inverse of SQL `CHECK`'s NULL-passes rule — and a `NULL`-caused refusal is called out explicitly in the failure detail (`guard evaluated to NULL (unknown)`, appended after an author `message` when one is declared, standing alone when it isn't) so a refusal is never silently indistinguishable from an ordinary `FALSE`. A bare or `old.`-rooted path's traversal is to-one-hop only, following `ref` values on the row the guard checks, depth-capped exactly like BQL (`BSL-REQ-43`); hop reads are point-in-time reads in the transaction snapshot, **not** locked — only the guarded row itself is (`BSL-REQ-48`). A refused write reports `422` `guard_failed` (see "Error shape" below), naming every guard that refused, not just the first. A metadata change that only adds, edits, or removes guards is a valid apply whose plan carries **zero DDL operations** and still advances the version chain and `model_checksum` — guards are pure metadata, exactly like `layouts`; enforcement in `commit()` simply starts reading whatever's currently reflected.

`GET /tenants/{key}/reflect/objects/{Object}/guards` and `.../relationships/{Name}/guards` are implemented reflect scopes (see "Using /reflect" below), never a `501` stub: every object's `guards` array reflects `{name, on, when, message, origin, _href}` per entry — `on` as a sorted array, `when` as the **verbatim authored source**, never re-rendered from the parsed expression — exactly what `commit()` enforces (`BSL-REQ-45`); `origin` is `{kind: "authored"}` for a hand-written entry, `{kind: "lock", index}` for one a `locks:` entry synthesized (below) — `index` names its position in the object's own `locks` array, not its position in `guards` — or `{kind: "edits", relationship, end}` for one an `edits:` block synthesized (see "Relationships" above): a relationship declaring `edits` on one end appends **two** such guards to that end's own parent object, both named `edits_<relationship>` — one `on: ["update"]` (`when` includes the null-safe no-op exemption), one `on: ["delete"]` (no exemption: a delete's own `current`/`old` are the same row, so one would trivially always hold). The same array appears inline under each object's own `/reflect/objects/{Object}` and `/reflect/relationships/{Name}` scope; an object's `/reflect/objects/{Object}` scope and its `/guards` scope both also carry a `locks` array — `{fields, unless, message}` per entry, exactly as authored.

**`locks:` (`DR-07`) — anonymous field freezes, null-safe.** An object key, sibling of `guards:`, that declares a field list frozen except under an escape condition, without hand-writing a chain of `x = old.x` comparisons — and without that chain's own trap: `NULL = NULL` is `Unknown`, not `TRUE`, so a hand-written freeze guard refuses **every** post-condition update on a record whose optional `ref` (or any nullable field) is null, even a no-op one on an unrelated field. `locks:` compiles the null-safe version instead:

```yaml
objects:
  Budget:
    attributes:
      name: {type: text}
      project: {type: ref, to: Project, optional: true}
    locks:
      - fields: [name, project]        # declared attributes only — not rollup-owned, not a lifecycle
        unless: "old.state = 'draft'"
        message: "Budget planning fields cannot change after draft"
```

This resolves to an ordinary `update` guard named `lock_<index>` (`<index>` is this lock's own position in `locks:`), appended to the object's `guards` *after* every authored guard, whose `when` is `(<unless>) OR (eq(f1) AND ... AND eq(fn))`, where `eq(f)` is `(f = old.f) OR (f IS NULL AND old.f IS NULL)` — built directly as AST nodes by the resolver, never by re-assembling and re-parsing text (there is no null-safe operator in the grammar, and `locks:` adds none). `fields` must each name a declared attribute of the object — not one a `rollups:` entry owns (computed on every write; freezing it is meaningless), and not a lifecycle name (a managed pseudo-attribute, not a real one) — any violation refuses `invalid_lock` at `{Object}.locks[{index}].fields`, naming the field. `unless` is an ordinary guard expression, bound under `update` exactly like a hand-written guard's `when` (`old.` legal, one verb only — freezing a field makes no sense on `create`, which has no `old.` to compare against, or `delete`, which has no post-write row); a syntax or binding error there is `invalid_lock` at `{Object}.locks[{index}].unless`. `message` becomes the synthesized guard's own `message`. A synthesized `lock_<index>` name colliding with an authored guard's own name is also `invalid_lock`, naming both. Once resolved, a lock's guard enforces exactly like any other — `check_guards` (`basel-engine`) treats every entry in `guards` uniformly, regardless of `origin`; `origin` exists for reflection, not enforcement.

**Which rule construct.** A predicate over the record's own final values, with no `old.`, no traversal, no aggregate, no time function, and no delete verb: write an **invariant**. A predicate over the record's own lifecycle state (`state = 'draft'`) is not an invariant candidate even though it passes that same test: as an invariant it would forbid ever leaving that state, freezing the lifecycle; keep it a guard. Freezing a fixed set of fields except under an escape condition: declare **`locks:`** rather than hand-writing the `x = old.x` chain — it compiles to the null-safe guard above automatically. A predicate that needs `old.`, a specific verb, or runs on delete: write a **guard**. A predicate over a related record's state that decides whether *this* record's own members may change at all ("lines may change only while the invoice is draft"): declare **`edits:`** (Stage 3 task 2; `DR-04b`, `BSL-REQ-232`) on the relationship's parent end, not a hand-written guard on the member referencing a `ref` back to the parent (`budget.state = 'draft'` on a line) — that pattern needs an explicit `ref` attribute, covers only the member's own verbs (never `link`/`unlink`, never the parent's own fields), and cannot be reached by the member's own guard evaluation at all once the traversal crosses a relationship without a `ref` (a guard's object is the record being written, and paths resolve only against it). A check that needs code: a **validator**. A question about who is acting: a **permit**; business rules do not go there, because permit refusals are opaque by design. Adding an invariant over rows that already violate it is refused at plan and apply (`BSL-REQ-102`), so preferring invariants never traps existing data.

**Rule-selection advisories (`DR-13`).** Every plan response carries `"advisories"` (see "Endpoints" below): non-blocking findings, never a reason to refuse a plan or an apply. Task 5's (`DR-13`) code is `guard_is_state_predicate` — a guard bound to both `create` and `update`, referencing no `old.` path, whose `when` would itself pass the invariant-admissibility gate above; its detail names the guard and the `invariants:` entry it could become. Task 4's (`DR-06`) code is `backfill_unused` — a declared `backfill:` the plan never actually reads, because the table turned out empty or the attribute already exists; see "Metadata grammar and type table" above.

## Aggregates, rollups, and invariants

Three constructs compose on top of a declared `relationships:` link (see "Relationships" above), all M4.1: **aggregates** (`SUM`/`COUNT`/`MIN`/`MAX` over a traversal whose far end declares `cardinality: {max: 1}`, expression-context only — legal inside a rollup's `value:` or a guard's `when`, refused inside an invariant's `assert` (declare a rollup and reference its own field instead), and never nested); **rollups** (a `rollups:` entry that owns the field named by its own key — `{<field>: {type, currency?, optional?, value}}`, the same keys an attribute takes, `value:` one of those aggregates — there is no separate `rollup:` keyword and no separate `attributes.<field>` entry; `targets:` is refused by name (DR-16: a rollup owns the field it targets, so naming it separately is no longer meaningful)); and **invariants** (`invariants:`, declared under an object or a relationship exactly like `guards:`, `{name: {assert, message?}}` — a fail-closed check bound to every verb on its own object, evaluated both on a direct write to that object and for every object a write *affected* through a rollup, whether or not that object's own derived value actually moved). Applying a metadata revision that adds or tightens an invariant validates **existing rows too**, not only future writes — a push can refuse `invariant_failed` because of data already in the tenant.

```yaml
objects:
  Widget:
    rollups:
      total:
        type: Money
        value: SUM(parts->amount WHERE stage = 'open')
    invariants:
      within_limit:
        assert: total <= 100000
        message: "Total exceeds the declared limit"
```

Multi-level aggregation composes rollups rather than nesting aggregates (permanently prohibited): a second object's own rollup can `SUM`/`COUNT` a rollup's *own* field across one more traversal, and a contributor write recomputes every level transitively, in one transaction. `ANY(t, e)`/`ALL(t, e)` quantifiers desugar to the same aggregate machinery at metadata resolution, SQL-conservative on `UNKNOWN`; reflection always serves the author's verbatim source, never the desugared form. **Over an empty contributor set an aggregate returns the operation's identity element where one exists, and NULL where none does** — `COUNT` and `SUM` are `0`, `MIN`/`MAX` are `NULL`, which is why a `MIN`/`MAX` rollup declares `optional: true` on itself (unchanged: the pre-DR-16 target attribute declared it instead). `SUM` is deliberately *not* SQL's `NULL`-on-empty: a childless object's rolled-up total reads `0`, in metadata expressions and in BQL alike.

**Link aggregates (M4.5).** The `->` aggregate form above counts/sums **related records**, which is why `BSL-REQ-91` requires the far end to declare `cardinality: {max: 1}` — without it, "three related records" and "one record reached by three links" are indistinguishable, and a genuine many-to-many like `BudgetaryPosition.accounts` cannot be aggregated at all. A second marker, `=>`, aggregates the **relationship instances** themselves instead: `COUNT(<traversal>=>)` counts links, and `SUM`/`MIN`/`MAX(<traversal>=><link property>)` fold one of the relationship's own **declared properties** — never a far record's attribute, which stays exactly as illegal through this form as through the record form, for the identical double-counting reason. Because a link is its own row with its own identity, `=>` aggregation is legal **regardless of far-end cardinality** — three links count and sum as three either way, so the ambiguity `BSL-REQ-91`'s `max: 1` rule guards against never arises here. Empty-set identity, three-valued NULL handling, and mixed-currency `Money` refusing at apply time all follow the record form unchanged (`COUNT`/`SUM` → `0`, `MIN`/`MAX` → `NULL`, no `AVG`):

```yaml
objects:
  BudgetaryPosition:
    rollups:
      account_count: {type: int, value: "COUNT(accounts=>)"}
    invariants:
      has_accounts:
        assert: account_count > 0
        message: "a position requires at least one account"
```

As with the record form, a link rollup is a declared `rollups:` entry — there is no separate `rollup:` keyword — and an invariant references its own field exactly as `BSL-REQ-98` already requires (aggregates stay refused directly inside `assert`). Membership changes (`link`/`unlink`, whichever endpoint authors them) and a write to a relationship's own declared properties are both contributor changes to a link rollup, recomputed by the same phase-2 final-state pass everything else in this section uses — which is what lets nested create in "Nested create and update" above satisfy `has_accounts` in the same transaction it creates the accounts.

**Read the live shape, don't assume it.** `GET /tenants/{key}/reflect/invariants` and each object's own `/reflect/objects/{Object}`/`/reflect/relationships/{Name}` scope (`invariants` key, name-keyed) serve every invariant's verbatim `assert`, `message`, object, and `_href` — the same verbatim-source discipline `guards`' `when` already follows. Every rollup and every invariant also carries `consistency_scope`: the declared transactional coupling a rollup creates between contributor and object, one entry **per level of its cascade** (a two-level chain reflects as two entries, never merged). `/reflect/objects/{Object}/guards` and `/reflect/relationships/{Name}/guards` additionally compose an `effective_conditions` array — hand-written guards and invariant-derived checks in one list, each entry naming its `origin` (`"guard"` or `"invariant"`) — satisfying "the effective condition, as a whole" without a second, parallel surface to keep in sync. `GET /tenants/{key}/reflect/rollups` enumerates the stable topology with a metadata-version ETag. Follow its `diagnostics_href` for bounded no-store measurements; invariant entries likewise link to paginated, read-only current violation diagnostics. Diagnostics never repair or recompute data and never carry a metadata ETag.

**Shapes the engine refuses, flags, or merely instruments** — three distinct tiers, never collapsed. *Refused* at metadata-apply time, by name, with the threshold in the detail: cascade depth or rollup count on one object beyond an engine budget — both budgets are themselves visible at `GET /tenants/{key}/reflect` under its `budgets` key, not a number you have to guess or hard-code. *Flagged* — legal, but consequential, surfaced through the same flagged-operation machinery an `ADD CONSTRAINT` against existing data already uses, requiring the plan-approval push option before it lands: adding a rollup to a relationship whose contributor already holds data, or adding a level to an already-existing cascade chain. *Reflected and instrumented, never refused or flagged* — data skew, which cannot be known at apply time: every write records typed tenant totals and per-rollup observations for `rollup.fan_in_links`, `rollup.matched_contributors`, `rollup.affected_objects`, `rollup.cascade_depth`, `rollup.recompute_ms`, and `rollup.lock_wait_ms` on the `basel_event` row it writes (`rollup_metrics`, present with zero totals rather than omitted on every create/update/delete/action-invoke), queryable via `GET .../history` exactly like redacted `proposals` are — a durable, queryable record, not an ephemeral one.

## Formulas

A **formula** (M4.6) is an object-level field computed **when read**, never stored: `elapsed_days`/`theoretical_amount` move as time passes with no write anywhere. It sits beside the stored family — `rollups:` (aggregate over a relationship, maintained on write, see "Aggregates, rollups, and invariants" above) and `formulas:` (any scalar computation, evaluated fresh on every read) are siblings, not the same construct under two names. A formula declares a name, a result `type` (the full type table), and a `value:` expression in the one shared grammar — own-row attributes, lifecycle states, rollup targets, other formulas on the same object, declared to-one traversal paths, the full function registry (time-dependent functions included), and history calls all resolve inside it:

```yaml
objects:
  Widget:
    attributes:
      planned_amount: {type: Money}
      date_from: {type: Date}
      date_to: {type: Date}
    formulas:
      elapsed_days: {value: "DATE_DIFF(TODAY(), date_from)", type: int}
      theoretical_amount:
        value: "planned_amount * (elapsed_days / DATE_DIFF(date_to, date_from))"
        type: Money
```

**No storage target exists or may be declared, and no `optional` field exists: every formula is always nullable**, regardless of its declared type — NULL operands propagate, division by zero yields NULL, empty `MIN`/`MAX` yield NULL, and open-`Money` unification failure yields NULL, so the contract states this uniformly rather than proving non-nullness per formula. **Not writable**: a formula name in any write body — create, update, or the update envelope — or among an action effect's own written values refuses `422 not_writable` naming the formula — the same rule a rollup's own target attributes already enforce. `computed:`/`derivations:` are refused-by-name pointers at the vocabulary a tenant actually wants: `derivations:` names a rollup (an aggregate over a relationship, belongs under `rollups:`), `computed:` names a scalar computation (belongs under `formulas:`) — neither is a reserved-for-later construct any more.

**Read parity — the same name means the same thing everywhere.**

- `GET .../objects/{Object}/{id}` and a list read carry **every** declared formula alongside the record's stored attributes — nothing to name, nothing to opt into.
- BQL computes only what a query actually **names** — a formula behaves exactly like an attribute in `SELECT`/`WHERE`/`ORDER BY`: project it, compare it, sort by it. `IS NULL`/`IS NOT NULL` are supported (a formula is always nullable, so this is the one null-test every formula answers); `IN` refuses a formula operand in v1 — compare with `=`/`!=` instead, or project it and filter client-side.
- `queryable: false` on a formula's own declaration withholds filter/sort (both together) while projection stays legal regardless — `/tenants/{key}/reflect` serves the derived `queryable: {projectable, filterable, sortable}` block per formula (`projectable` is always `true`; `filterable`/`sortable` follow the result type's own operator set, folded through the opt-out) so a client never has to re-derive it.

**The invariant gates — two, both static, both by name.** An invariant's `assert` may reference a same-object formula only when it passes **both**: `422 invariant_requires_determinism` when the formula's closure calls a transaction-time function (`TODAY()`/`NOW()` and their zoned variants) — an invariant checks state at arbitrary later commits, so a time-dependent read would drift with the clock rather than with any write; `422 invariant_requires_closed_dependencies` when the formula's closure reads anything beyond its own row and rollup targets — a to-one path or an aggregate of any kind, correlated included, reads a far row nothing re-checks this invariant against when it changes. Both facts are published per formula at `/tenants/{key}/reflect` (`deterministic: true|false`) rather than left for a client to infer from a refusal. History calls (`entered_at`/`ended_at`, below) pass both gates — they are pure over the record's own event log, no clock involved, and change only on a write to the record itself.

**Composed capabilities and the `usable_in_invariants` assertion (DR-14).** `/tenants/{key}/reflect` serves a `capabilities` block on every formula, right after `queryable`, so a client learns the two facts above (and one more) without re-deriving them from the raw closure facts: `usable_in_invariants` (`deterministic && write_local` — the invariant gates above, composed) and `reachable_through_traversal` (`value_closure_only` — Task 6's to-one-traversal admissibility). Each is `{"ok": true}` or `{"ok": false, "reason": "..."}` — `reason` omitted exactly when `ok` is `true`. An author may also declare `usable_in_invariants: true` directly on the formula itself, asserting the gate holds rather than waiting to discover it at a distant invariant reference: resolution refuses an assertion that does not hold — `422 invariant_requires_determinism` (naming the offending function) or `422 invariant_requires_closed_dependencies` — at the formula's own `<Object>.formulas.<name>.usable_in_invariants`, the identical codes an invariant reference itself refuses with. `usable_in_invariants: false`, or omitting the key (the default), makes no claim and is never checked.

**Correlated aggregates (M4.6 §5).** A `formulas:` value — and a BQL expression, one grammar — may aggregate over an **object**, membership decided by a `WHERE` predicate rather than a declared relationship traversal:

```
COUNT ( object-name WHERE predicate )
(SUM|MIN|MAX) ( object-name WHERE predicate -> value-expr )
```

Inside `predicate`, a bare path binds to the **contributor** (the aggregated object) and a **`this.`**-prefixed path binds to the **object** — the formula's own row, including its declared to-one traversals (`this.position.account`); `this` is a reserved authored name, exactly like `id`/`old`. `value-expr` (required for `SUM`/`MIN`/`MAX`, illegal for `COUNT`) roots in the contributor's own scope only — `this.` is illegal there in v1. **Correlation is a compiled fact, not a syntax rule**: the mandatory `WHERE` alone does not make an aggregate correlated — a predicate with no `this.`-rooted reference is an object-wide scan, refused `422 uncorrelated_aggregate` naming the aggregate (a report belongs in BQL, not a formula). A formula's own `value:` (above) is always the author's verbatim source — that is the one place the predicate text lives. `/tenants/{key}/reflect` additionally serves a `correlated` **array** on the formula entry, one element per correlated aggregate the closure actually contains (up to 4, the limit below) — each element names the contributor `object`, the aggregate `function`, the `this.`-rooted `paths` it correlates on (dotted, prefix stripped), and the aggregate's `result_type`; it does not re-derive or duplicate the predicate text, only the structural facts the compiled node itself carries. A closure composing more than one correlated aggregate — legal up to the limit below — reflects every one of them, in the order they appear, never only the first.

**History functions in reads.** `entered_at(lifecycle[, state])`/`ended_at(lifecycle)` (see "Reading history" under "Actions" below for the underlying event log) are legal inside a `formulas:` value and — as of M4.6 — inside a BQL `WHERE` clause, both lowering to the identical correlated subquery the shared renderer already builds for one evaluation instant per request; BQL's earlier standing refusal on a history function in `WHERE` is lifted. A formula naming one is always nullable (no entry into the named state yet reads NULL) and evaluates fresh on every read — no stored copy exists. `IN` still refuses a bare history-function operand (no single rendering for a subquery inside an `IN` list); compare with `=`/`IS NULL` instead, or project it.

| Limit | Value |
|---|---|
| Formula composition depth | 8 |
| Correlated aggregates per formula closure | 4 |
| Read statement timeout | 10s |

## Functions

Custom code in Basel is a **function**: an author-supplied **WebAssembly Component Model** binary — a **module**, kept under `wasm/` — sandboxed, budgeted, and versioned against a normative WIT contract. Where a function is attached decides what it may do: **called directly** it is pure (typed compute with no database access); **behind a validator** it checks a write (a procedural accept/reject inside `commit()`); **on an action or transition** it reads and returns a proposal. No function imports the network, the filesystem, an ambient clock, or an ambient random source; every capability it uses is declared, scoped, and checked at metadata apply time, never discovered at invocation. A module whose world does not fit where it is attached refuses at apply `function_attachment_mismatch`, naming the attachment and what the module actually is.

A named function is declared once, in `functions/<name>.yaml`, pointing at one export of one module; any number of actions and transitions may name it:

```yaml
# functions/next_values.yaml
name: next_values
code: {module: wasm/compute.wasm, export: next-values}
```

### Validators

A validator is a procedural admission check: it checks a proposed operation on its object, at the transaction's final state, and answers accept or reject — admission control at write time, not a standing guarantee over existing rows. It is **not a persistent invariant**: a validator may read across records at check time, but nothing re-runs it later when a remote record it once read changes — that standing-guarantee job belongs to a write-local invariant instead (possibly over a rollup). An author who needs cross-record check at the endpoint declares a validator; an author who needs a guarantee over existing rows declares an invariant; the two compose, and neither substitutes for the other.

A validator attaches to an object, names its code with `code: {module, export}`, and declares `on:` — its event subscription, deliberately separate from its read scope (listening never implies read authority, and a traversal in scope never implies a subscription to its own links). Scalar entries name plain record operations; `link:`/`unlink:` entries name the relationship a membership change is subscribed on, explicitly:

```yaml
objects:
  Widget:
    validators:
      check_gadget:
        on: [create, {link: gadgets}]
        code: {module: wasm/check.wasm, export: validate}
        scope:
          reads:
            Gadget: {fields: [status]}
          budgets: {rows: 32}
```

`scope:` may only NARROW the module's own embedded contract, per dimension (an omitted dimension keeps the contract's own figure) — never widen it; a `scope:` that would widen refuses at apply. `/tenants/{key}/reflect/validators` (and the same object's own `/tenants/{key}/reflect/objects/{Object}` entry) publishes the resolved EFFECTIVE scope — the contract narrowed by `scope:`, not either alone — beside the resolved per-invocation budgets, the bound export's WIT signature, and the module's sha256, so a client never has to fold the two together itself. A rejecting validator's wire shape is `422 validator_rejected`, element `{Object}.validators.{name}`, fixed `detail` `validator rejected the proposed change`, and fixed `domain_code` `rejected`. Runtime module codes/messages and raw function diagnostics are not public response or log channels; invocation costs retain categories and budgets.

### Pure functions

A function no action names is **called directly** and must be pure: typed parameters (plus an optional entropy seed) to a typed result — no database imports, no reads, no snapshot, no transaction. `POST /tenants/{key}/functions/{name}` splits arguments from execution controls so a reserved control name can never collide with an authored parameter: `{"arguments": {...}, "execution": {"seed": "<u64 hex>"}}` in, `{"result": <typed>, "execution": {"seed": "<u64 hex>"}, "module": "<sha256>"}` back. Entropy is **seed-as-data, not a host generator**: a module declaring the `entropy` capability has `seed: u64` as its bound export's verified FIRST parameter — the host supplies it (freshly generated, or the caller's own `execution.seed` replayed byte-for-byte), the module owns its own pseudo-random algorithm, and `execution.seed` appears in EITHER envelope if and only if the capability is declared. Unknown function name refuses `404`; a function attached to an action refuses `422 function_attachment_mismatch` (it runs when its action is invoked); a parameter shape or type mismatch refuses `422 invalid_type` (or `unknown_attribute` for a missing/unrecognized key) naming the precise dotted/indexed element (`arguments.rule.count`); a budget breach refuses `422 limit_exceeded` naming the budget. A pure function call carries **no audit event** — a read-shaped surface, invocation metrics only, exactly like a plain query.

### Functions on actions

An action — a plain action or a lifecycle transition — may name a function with `function: <name>` (M5.1) beside its declared `effects:`. The function runs on the object's pre-proposal snapshot and returns a bounded typed proposal amended into the invocation's own batch. It has the same scoped read host API a validator uses (`get`, `traverse-one`, `traverse-many`, `query`, `aggregate`), plus a `writes` scope of its own — the write-set half no validator has. `scope: {reads, writes, queries, budgets}` sits on the action, so one function can run with a different scope on each action that names it.

```yaml
objects:
  Widget:
    actions:
      restock:
        params:
          limit: {type: int}
        function: restock
        errors: {nothing_to_restock: 'No gadgets need restocking.'}
        scope:
          reads:
            Gadget: {fields: [status]}
          writes:
            Gadget: {transitions: [status]}
```

A function rejects with a code only. Declare `errors: {code: message}` beside `function:`: at most 64 ASCII identifier codes of 1–64 bytes, with nonempty literal messages of at most 1024 UTF-8 bytes. Messages are never interpolated. Omission declares no public rejection codes; `errors:` without a function is invalid (`invalid_function`). An undeclared returned code fails as sanitized `function_scope_violation`; an oversized code fails the result budget. Guest rejection strings are not logged.

Two rules gate every operation the function returns before it joins the batch (design §5). The write-set rule: every operation is checked against the effective `writes` scope: object and verb, fields, lifecycle, relationship. The provenance rule: a function may only target the record it runs on, records it observed through its scoped reads, ids named by its typed params, and its own earlier creates; anything else fails as `function_scope_violation`.

**The snapshot rule.** The function runs on the pre-proposal snapshot before any write; a record the function both observed and writes is re-checked after the lock pass and refuses `409 conflict` if it moved.

**Composition with declared effects (design §6, ADR-0061).** `effects:` and `function:` may coexist on one action: declared `effects:` compile first, the function's operations follow (positioned `function[<i>]`). Writes to **different fields** of one record merge into one record after the write — a transition's function may update its own record beside the state change, checked once by guards, invariants, authority and the version check, with one audit event. A field (or link, or a deleted record) written by both refuses `422 proposal_conflict`, naming both positions; on a transition, a function writing a field the transition itself sets refuses `422 transition_field_conflict`, and is refused at push (`invalid_function`) when the function's write scope already allows it. Creates never conflict.

**No reentrancy (design §1).** A function runs only when its OWN action is invoked directly. A lifecycle transition reached as a sibling `effects:` move, or as an operation a function itself proposes, still changes the record's state, but does not run that transition's own function — a function can never trigger, directly or through another function's proposed operations, a second function's run.

| Code | Meaning | Status |
|---|---|---|
| `function_rejected` | the function selected a declared code; `domain_code` is that code and `detail` is the action's static metadata message | 422 |
| `proposal_conflict` | effects and function wrote the same field, link or deleted record | 422 |
| `transition_field_conflict` | a transition's function wrote a field the transition itself sets | 422 |
| `limit_exceeded` | a budget breach, naming the budget and layer | 422 |
| `conflict` | the snapshot rule's record changed since it was read (`conflict:record_changed`) | 409 |
| `function_scope_violation` | a read or write outside the effective scope, an unobserved target, or an undeclared rejection code | 500-shaped |
| `function_trap` / `function_host_error` | as for validators | 500-shaped |

`/tenants/{key}/reflect/functions` lists every attachment under `attachments` — element, object, action, kind, function, module sha256, effective `reads`, effective `writes`, budgets, and WIT signature — and the same fields appear inline as `function` on every action/transition entry under `/tenants/{key}/reflect/objects/{Object}`, `/reflect/actions`, and `/reflect/lifecycles`: the key is ALWAYS present, `null` only when no function is named (`BSL-REQ-15`). A successful invocation's metrics carry `rollup_metrics.functions[<element>]` beside `validators`.

**The JSON mapping is normative** — "types come from WIT" is not itself a stable API. Every function parameter/result, and every reflected WIT signature, follows one table: `s8`..`s32`/`u8`..`u32` as plain JSON numbers, `s64`/`u64` as DECIMAL STRINGS (JSON numbers are only safe past that width), `f64` as a JSON number refusing NaN/±Inf at the boundary in EITHER direction, `bool`/`string` verbatim, `option<T>` as the value or `null`, `variant` as `{"tag": ..., "value": ...}` (`value` is `null` for a payload-less case, never an omitted key), `enum` as the case-name string, `list<T>` as a JSON array, and `record` as a JSON object using the WIT field names verbatim — every declared field required, no unrecognized keys tolerated. Type nesting past 16 refuses at apply, as do resources, handles, streams, futures, and `f32`. `/tenants/{key}/reflect/validators` and `/tenants/{key}/reflect/functions` publish every bound export's signature in this exact vocabulary.

### Budgets

Every figure below is a specification number, published here and subject to the same amendment discipline every other refuse-tier threshold in this document already follows — never a silent runtime knob. The first table bounds ONE invocation; the second bounds the aggregate across every validator invocation inside ONE `commit()` — a wide batch crossing several validators is bounded by the total, not merely by each invocation alone.

**Per invocation:**

| Budget | Validator | Pure | On an action |
|---|---|---|---|
| Fuel (deterministic; not "instructions") | 100M | 100M | 100M |
| Linear memory | 64 MiB | 64 MiB | 64 MiB |
| Wall clock | 100 ms | 1 s | 500 ms |
| Rows read (in scope) | 256 | — | 256 |
| Host calls | 128 | 8 (entropy only) | 128 |
| Result size | 1 KiB (reject message) | 256 KiB | 64 KiB (encoded proposal) |
| Parameter size | — | 64 KiB | — |
| Operations returned | — | — | 32 (shared with declared effects) |

**Per proposal (validator totals, across every invocation in one `commit()`):**

| Budget | Value |
|---|---|
| Wall clock | 2 s |
| Fuel | 1B units |
| Rows read | 1024 |
| Host calls | 512 |

Breaching either layer refuses `422 limit_exceeded`, naming both the budget and the layer, and always fails closed — a function never observes a partially completed call as though it had finished.

### Supported ABI

The normative WIT, package `basel:function@1.0.0`, is served at `GET /abi/basel-function/1.0.0/function.wit` (`text/plain`, content ETag) — the same file the repository keeps at `wit/basel-function/function.wit`. `GET /abi` indexes every served package version. Basel serves every supported EXACT interface version, never merely "both majors" — a module targeting anything outside the supported set refuses at apply, naming it. The current supported set, one world per place a function may be attached:

- `basel:function/validator@1.0.0` — behind a validator (it checks a write)
- `basel:function/pure@1.0.0` — called directly (a pure function)
- `basel:function/proposer@1.0.0` — on an action or transition (it reads and returns a proposal)

The `reads` interface carries one import beyond the four record reads: `query-by-name(name, page)` invokes a declared application query (`queries/<name>.yaml`) under the invoking function's own read scope. A function's scope names the queries it may invoke in `scope.queries` — a SIBLING of `scope.reads`, because `reads:` is keyed by object and a `queries` key inside it could not be told from an object of that name — and apply proves each one reads nothing the scope does not already reach. The proof covers the plan's WHOLE read, not its projection alone: a `where` decides which rows come back and an `order_by` decides their rank, and both are readable through the page, so `select`, `where` and `order_by` are checked alike. Its object must be one that `scope.reads` names WITH AN EXPLICIT `fields:` list — an omitted dimension means "the module's own contract", which resolution cannot see, so there would be nothing to prove against; that refusal is `invalid_function` at `<Object>.actions.<name>.scope.queries[<i>]` (and `invalid_validator` at `<Object>.validators.<name>.scope.queries[<i>]` for a validator's own scope). Every name must then be `id`, a field in scope, or a formula whose whole closure is in scope; a hop is walked only through a `ref` field or a traversal in scope, and the far object and far field must be in scope too. A read-time formula is allowed unchecked in `select` ALONE — nothing projects it, so a guest never observes its value — but the moment the same name also appears in `where` or `order_by` it is checked on its whole closure, because there it is readable through membership and rank. A plan declaring `parameters:` refuses: this endpoint binds no `$name` token.

**Building a module from HTTP access alone.** Generate guest bindings from the served WIT with `wit-bindgen` (world `validator` or `proposer`; a pure module declares its own world with no imports), build for `wasm32-unknown-unknown`, convert the core module with `wasm-tools component new` (no WASI adapter — the closed import set is verified structurally), and append exactly one custom section named `basel-contract` holding RFC 8785 canonical JSON that satisfies `GET /abi/basel-function/1.0.0/contract.schema.json`. The Rust guest SDK, `basel-function-sdk`, ships as source in the repository's `examples/modules/` and is not published to a registry; nothing in it is required — the WIT and the schema are the whole contract.

`/tenants/{key}/reflect/functions` lists every declared function (its `code`, its `role` — `pure` or `proposer`, decided by where it is attached — and `attached_to`), every `wasm/` binary (repository path, sha256, its own declared ABI string, and every validator, directly called function and action that uses it), and every `attachments` entry of a function to an action or transition.

### Reserved

**M5.2 (named, reserved, design owed):** the scheduling and external-effects tier — scheduled actions, transactional outbox/consequence records, after-commit workers, retries and idempotency. Function-to-function and function-to-action calls stay refused **permanently** (no reentrancy, any attachment — see "Functions on actions" above); a managed source-build service stays out of scope too — Basel accepts only locally or CI-compiled modules, never builds one itself.

## Actions

An **action** — a plain action, or a lifecycle transition (an action that also moves the record's state) — is a named, typed, guarded invocable declared on an object *or* relationship (design doc §3.1, `BSL-REQ-76`): the wire form of "capabilities", not generic mutation. A transition additionally declares `from`/`to` and belongs to a named lifecycle; invocation, guards, effects, parameters, and audit are otherwise identical for both — the same `POST .../actions/{name}` surface invokes either kind, on an object *or* a relationship alike.

**Discovering what a tenant declares — never hand-enumerated here**, per the same rot-avoidance discipline the type table and grammar sections follow: a tenant's own actions are metadata, not part of this engine-wide document, and change with every apply. `GET /tenants/{key}/reflect/lifecycles` lists every declared lifecycle across every object (a relationship never has lifecycles) — each with its managed state `attribute`, `states` (with `outcome`, `null` when none declared), `initial`, and `transitions` keyed by name, each transition's own `from`/`to`/`params`/`guards`/`effects`. `GET /tenants/{key}/reflect/actions` lists every declared plain action, object AND relationship together, each entry naming what it is declared on under `object` or `relationship` (the key says which) alongside the same `params`/`guards`/`effects` shape. Both also appear inline under the owning object's own `/reflect/objects/{Object}` or `/reflect/relationships/{Name}` scope (`lifecycles`/`actions` keys, keyed by name) — objects additionally carry `rollups` there (design doc §4–5, `BSL-REQ-78`/`79`): each entry's own `type` and **verbatim** `value` source, in dependency-evaluation order. Every guard's `when` and every effect's bound value render **verbatim authored source**, never the parsed expression — the same `BSL-REQ-45` discipline the plain `guards` scope follows (see "Guards" above), extended here to actions.

**Effect vocabulary (M4.5).** A transition's or action's declared `effects:` compile to one proposal, applied atomically with the record write itself: `set:` (assign this record's own attributes) and `transition:` (advance this record's own lifecycle) as before, now joined by four verbs that reach other records through the object's declared relationships and compile into that same widened proposal IR: `create:`, `update:`, `link:`, and `unlink:` (see "Nested create and update" above for their record-endpoint counterpart). **Scope is model-visible, not runtime-discovered** (reflected as each effect's `scope`): an effect may only reach a target **through a relationship declared on the object**, and may only `update:` through a declared **to-one** path — a reader of the metadata knows every action's maximum blast radius without reading any function, and naming an undeclared path is an authoring-time refusal, never a runtime surprise. `create:` under a declared traversal both creates the related record **and** links it — creation through a declared relationship *is* the link, so no separate `link:` is needed for a record this same effects list just created. `unlink:` addresses the link it removes the same way a nested write's `unlink` does (see "Nested create and update" above): a declared param or local link id, or an endpoint pair, which must match **exactly one** link or refuses `422 ambiguous_link`.

**Sibling transitions.** An action may `transition:` a record other than its own object — one it just created in the same effects list, or one reachable by declared relationship — with **explicit parameter mapping only**: the mapping's keys are the target transition's own declared param names, and each value is a bound expression — the invoking action's own declared params, literals, or paths rooted at the object, the identical binding scope a guard's `when` resolves against (see "Guards" above). A bare name checks the invoking action's own declared params first, falling back to an object attribute of the same name; the two never actually collide, because declaring a param with the same name as an object attribute already refuses `name_collision` at metadata apply, for every action's params, not just a sibling transition's mapping. A mapping that doesn't cover every required param of the target transition is an authoring-time refusal at metadata apply, never a runtime surprise, and the target transition's own guards still evaluate against the final state — a sibling transition moves state and binds its mapped params, it is not a guard bypass. **Fail-closed narrowing:** a sibling transition may not license a target transition that itself declares `effects:` of its own — every transition of the target lifecycle ending at the named state (regardless of `from`, since the record's state isn't known at metadata-apply time) must declare no effects, or metadata apply refuses `unsupported_construct` naming it; cascading into the activated transition's own effects is not lifted by M4.5.

**Budget.** One action invocation's compiled effects are capped at **32 operations** (before any cascade) — see the limits table under "Nested create and update" above — breached as `422 limit_exceeded` naming the cap. Combined with declared reach and the standing cascade-cycle refusal, a compiled batch is finite and its size is inspectable from metadata alone, without ever invoking it.

**Read bindings (`with:`, ADR-0060, BSL-REQ-310).** An action's `with:` block, beside `effects:` — `with: {team: category.default_team}` — binds one record by name for its effects: each binding starts at a declared `ref` param (or an earlier binding in the block) and follows `ref` fields only — at most 4 segments, ending at one record, never a list or query. The effects read `team` as a `ref` value. A `- with:` entry inside `effects:` refuses, naming the block. The records it reads are part of the action's declared scope (reflected as `{"kind": "with", "reads": [...]}` ahead of the effects) and are checked against the **caller's** read authority: a missing record, a record the caller cannot read, or a reference field it cannot read all refuse `422 binding_not_found` alike, disclosing nothing; a NULL optional reference binds NULL. A binding that reads a different record after the lock pass than before refuses `409 conflict`.

**The transaction instant and Duration arithmetic (ADR-0060).** `NOW()` in an effect's value is the write's own transaction instant — the same value for every effect on every record of the invocation, and exactly the audit event's `occurred_at`. `TODAY()` is refused in effects. Fixed-elapsed arithmetic: `Timestamp - Timestamp` is a `Duration`, `Timestamp ± Duration` a `Timestamp`, `Duration + Duration` a `Duration` (a day is 24 hours; no calendar, business-day or zone rules); any NULL operand gives NULL. Pause accounting, for example: `paused_total: "COALESCE(paused_total, DURATION 'PT0S') + (NOW() - paused_at)"`.

**One audit event per invocation, unchanged.** A multi-operation effects list still writes exactly one `basel_event` row per `POST .../actions/{name}` call, carrying the whole applied batch verbatim and every affected record's resulting version — the audit contract this document already states under "Reading history" below did not change size limits or event count when the nested and action write paths widened what one invocation may touch.

**Invoking one:**

```
POST /tenants/{key}/objects/{Object}/{id}/actions/{name}
{"<declared param>": <value>, ..., "reason": "..."}   # reason optional
```

The body is the action's declared params as a flat JSON object, each typed exactly like a declared attribute — plus the same optional top-level `reason` audit field `create`/`update`/`delete` accept (design doc §11 P5, `BSL-REQ-84`): a **declared** `reason` param (an action may name one like any other param) is the one channel once it exists — it binds normally and is type-checked like every other declared param (required unless it declares its own `optional`/`default`), and its bound value becomes the invocation's own audit `reason`; only when the action declares **no** `reason` param is the top-level envelope field used instead, popped before param validation so it can never collide with a real param. Success is `200` with the updated record, `TypeID`-rendered exactly like `create`/`update`'s own response (see "Ids: TypeID on the wire").

**`optional`/`default` params (Stage 2 task 7, DR-10a).** A scalar param may declare `optional: true`, or a `default:` literal (which implies `optional` — the two never disagree), with the exact `default:` shapes and same metadata-resolution validation gate an attribute's own `default:` uses (see "`default:`" above) — refused `invalid_default` at `{Object}.actions.{name}.params.{param}.default` (or the transition equivalent) when it doesn't type-check, or (for a named-typed param, Task 6, DR-09) doesn't satisfy the named type's own `check`. An invocation that omits a param declaring a `default` binds that literal; one that omits a param declaring only `optional` (no default) binds `NULL` in guard/effect scope — a guard that then evaluates the param directly (rather than through an explicit `IS NULL`) sees `Unknown` and refuses fail-closed exactly as an optional attribute's own `NULL` does (`BSL-REQ-47`), so authors guard an optional param the same way. An explicit `null` is never "absent": supplied for a required param it refuses `validation_error "required"` exactly as before; supplied for an optional one it binds `NULL` same as omission, and never substitutes `default` (applied only when the key is missing entirely). A sibling transition's explicit mapping ("Sibling transitions" above) may likewise omit any optional param of the licensing candidate — only a **required** param of every candidate ending at the named state must still be covered. Reflection carries `optional` on every param (always present, a bool) and `default` (present only when declared, same absent-means-none convention as an attribute's).

**Refusals**, in the same uniform error shape as every other endpoint (see "Error shape"): unknown object/relationship → `422 unknown_object`; a malformed or wrong-`TypeID`-prefix `id` → `422 invalid_bind`/`id_prefix_mismatch` (`BSL-REQ-25`, checked before `id` is ever looked up); an unknown action name, or no row at `id` → `404`; a missing or mistyped param → `422` `validation_error`/`invalid_bind`, shaped exactly like an attribute type error, `element: "<Object>.<action>.<param>"`; a guard refusal → `422 guard_failed` with the additive `errors[]` array (`BSL-REQ-42`) — element `"<Object>.<guard name>"`, the **same** format a plain object/relationship guard failure uses (both routes through the identical evaluator), not qualified by the action name; a wrong-`from` transition (the record isn't currently in one of the invoked transition's declared `from` states) → `409 conflict`, with `diff: {"current": "", "from": []}`.

**Reading history:** `GET /tenants/{key}/objects/{Object}/{id}/history?limit=&offset=` (design doc §11 P4, `BSL-REQ-84`) returns a bare JSON array of that record's own audit events, newest first, `limit` defaulting and capped exactly like BQL's own pagination (see "BQL" below), `id` and `offset` following the same conventions as everywhere else on the wire:

```json
[{"id": "evt_...",
  "occurred_at": "2026-01-01T00:00:00.000000Z",
  "operation": "transition:{Object}.{lifecycle}.{name}",
  "proposals": [{"op": "set", "attribute": "...", "value": "..."}, ...],
  "metadata_version": 3}]
```

`operation` names the endpoint: `create`/`update`/`delete` for generic CRUD, or `action:<Object>.<name>`/`transition:<Object>.<lifecycle>.<name>` for an action invocation. Public history never returns invocation parameters, free-text reason, session or credential identifiers; canonical audit storage retains that evidence. `audit.read` does not restore private metadata.

`404` on a nonexistent `id`; `422 unknown_object` on an unknown `Object`; `422 limit_exceeded` over the pagination cap. The reserved, read-only `Event` pseudo-object exposes the identical history as queryable BQL state (`FROM Event`, `GP-08` taken literally: history is queryable, not just fetchable) — reflected at `/reflect/objects/Event` (`kind: "event"`) exactly like a real object's own scope, so a client that already understands object reflection needs no new parsing logic for it; every write path (`create`/`update`/`delete`/an action invocation) writes exactly one event per invocation, inside the same transaction as the row write itself, so a record's history and its current state can never disagree about what happened.

## BQL

A `SELECT`-shaped read-only query language (design doc §6), stratified by precedence: `NOT` binds tighter than `AND`, which binds tighter than `OR`. Parentheses override.

```
query      := SELECT projection FROM ident [WHERE or_expr]
              [ORDER BY ordering] [LIMIT uint] [OFFSET uint]

projection := projection_item (',' projection_item)*
projection_item := path | value_expr AS ident
path       := ident ('.' ident)*
value_expr := CASE WHEN or_expr THEN value_expr [WHEN ...] ELSE value_expr END
            | value_expr ('*' | '/') value_expr
            | value_expr ('+' | '-') value_expr | '-' value_expr
            | function_call | aggregate_call | path | literal

or_expr    := and_expr (OR and_expr)*
and_expr   := not_expr (AND not_expr)*
not_expr   := NOT not_expr | primary
primary    := '(' or_expr ')' | comparison

comparison := path op literal
            | path IN '(' literal (',' literal)* ')'
            | path IS [NOT] NULL

op         := '=' | '!=' | '<' | '<=' | '>' | '>='

ordering   := order_term (',' order_term)*
order_term := path [ASC | DESC]

literal    := string | number | bool | number CURRENCY
            | DATE string | TIMESTAMP string | DURATION string | DATERANGE string | UUID string
```

**Value precedence** is primary/function/CASE, unary `-`, `*`/`/`, then `+`/`-`; comparisons sit above that value ladder, followed by `NOT`, `AND`, and `OR`. Any non-path projection requires `AS <alias>` so the result key is stable; a bare path keeps its authored dotted path.

Aggregates accept contributor-scoped value expressions: `SUM(lines->quantity * unit_price WHERE active = true)`. `COUNT` alone takes no value. Money literals are currency-qualified (`12.50 USD`); open-Money aggregation requires a direct positive currency equality conjunct, and Basel never performs implicit currency conversion.

A stored `rollup` is deterministic and maintained on writes; a `formulas:` entry (M4.6, see "Formulas" above) is read-time and unstored — `computed:`/`derivations:` are refused-by-name pointers at those two real constructs now, not a still-reserved family. `TODAY()`/`NOW()` (and their zoned variants) bind to one evaluation instant per request and are legal in BQL and in a formula's `value:` alike; a stored rollup's own `value:` is always a lone aggregate call (M4.6 Task 9's admission rule), so a time-dependent function has nowhere to appear there, though deterministic CASE/date/string functions remain usable inside that aggregate's own arms.

Built-in functions (from the engine registry):
- `COALESCE` — query, rollup
- `LOWER` — query, rollup
- `UPPER` — query, rollup
- `TRIM` — query, rollup
- `LENGTH` — query, rollup
- `TEXT_SET_CONTAINS` — query, rollup
- `CONTAINS` — query, rollup
- `CONCAT` — query, rollup
- `DATE_ADD` — query, rollup
- `DATE_DIFF` — query, rollup
- `DATE_TRUNC` — query, rollup
- `TODAY` — query
- `NOW` — query

`CONTAINS(text, search)` is a case-insensitive substring test returning `Bool` (both sides lower-cased; NULL if either is NULL). A function call is a value, not a predicate, so compare it: `WHERE CONTAINS("name", 'acme') = true`. It is how a record picker searches past its first page.

**A bare string is never coerced.** `DATE '2026-08-22'`, `TIMESTAMP '2026-08-22T10:00:00Z'`, and `UUID '...'` are explicit; comparing a `Date` attribute to a bare string is a compile error naming both types, not a silent cast. **NULL is three-valued**, as in SQL: `NOT (amount > 100)` does not match rows where `amount` is null. `IS NULL`/`IS NOT NULL` are the only null tests; `= null` is a compile error.

**`LEFT JOIN` null semantics — the single most common way to misread a result.** Traversing a nullable `ref` (e.g. `account.tier`) emits a `LEFT JOIN`, so `account.tier = 'gold'` **excludes** rows with no `account` at all — it is not equivalent to "account is missing or gold"; it is `WHERE gold`, and a null-valued join column never equals anything.

**Deterministic pagination.** `ORDER BY` always has `id ASC` appended as a final tiebreaker, so `LIMIT`/`OFFSET` paging is stable even over a non-unique sort key. `LIMIT` defaults to 100 and is capped at 1000.

| Limit | Value |
|---|---|
| Query text | 16 KiB |
| `LIMIT` | default 100, max 1000 |
| Traversal depth | 4 segments |
| Distinct joins | 12 |
| `IN` list | 500 literals |
| Predicate nodes | 200 |

Every limit is enforced at compile time, with an error naming the limit. Which operators each attribute supports — including whether it is orderable — is served per-attribute by `/tenants/{key}/reflect` (`BSL-REQ-09`/`BSL-REQ-16`); AGENT.md deliberately enumerates no type-level list here, so this document cannot rot as types evolve — see below.

**`id` is a pseudo-attribute (`BSL-REQ-23`).** Bare `id` and `<path>.id` resolve in projection and `WHERE` everywhere a declared attribute would — e.g. `SELECT id, name FROM Widget`, `WHERE account.id = UUID '...'` — accepting the same `TypeID`-or-bare-uuid literal every id-accepting boundary does (see "Ids: TypeID on the wire" above). Its operator set is equatable-only: `=`, `!=`, `IN` — never `<`/`<=`/`>`/`>=`, and **never valid in `ORDER BY`**; it already appears there implicitly, as the tiebreaker described above, and naming it explicitly is a compile error. `/tenants/{key}/reflect` lists it as an `engine_owned` entry in every object's `attributes`, alongside every declared attribute.

**`POST /tenants/{key}/query`'s result shape** is `{"columns": [...], "rows": [[...], ...]}`: `rows` in `columns` order, `columns` one entry per projected path — `{"path", "type", "id_prefix"}`. `path` is the dotted path exactly as authored; `type` is the lowercase Basel type (`"text"`, `"money"`, `"enum"`, `"ref"`, `"id"`, …) — `"id"` for both bare `id` and any `<path>.id` projection, never `"ref"`, even though both render identically on the wire as `TypeID`s; `id_prefix` is the target object's `TypeID` prefix for a `"ref"`- or `"id"`-typed column, `null` for every other type, including a declared `uuid` attribute, which stays bare.

## Using /reflect

`AGENT.md` (this document) explains how to talk to Basel; `/tenants/{key}/reflect` reports what a given tenant's metadata contains — its objects, attributes, and relationships, all serialized from the resolved `Model` the engine is currently holding. Neither duplicates the other.

**Scoping by path.** The tree is reachable at any depth: the tenant root (`/reflect`), the object list (`/reflect/objects`), one object (`/reflect/objects/{Object}`), one attribute (`/reflect/objects/{Object}/attributes/{attr}`), the relationship list (`/reflect/relationships`), and one relationship (`/reflect/relationships/{Name}`, see "Relationships" above). Request whatever scope you need; nothing requires walking from the root first.

**`_links`/`_href` traversal.** Every scope carries `_links` with `self`, `parent`, and `root`, and every nested element (an object in a list, an attribute in an object, a relationship target) carries its own `_href`. An agent that lands anywhere in the tree can reach everywhere else without prior knowledge of the URL structure — construct nothing by hand.

**Full names (ADR-0068, `BSL-REQ-322`).** Every object, relationship, named type and query has a full name, `namespace:short`. The tenant's own elements are in `core` (`core:Contact`), Basel's built-ins in `basel` (`basel:Principal`, `basel:Event`), and a vendored package's elements in the namespace its release declares (`crm:Contact`). Reflection reports each such element's `name` as its full name, beside `namespace` and `provenance` — `{namespace}` for a `core` or `basel` element; for a packaged one also `package`, `version`, `commit`, `release_digest` (from `basel.lock`) and the declaring `path`. Every field whose value names one of these elements carries the full name too — a `ref`'s `to`, a relationship end's `object`, a query's `from`/`target`, a `select`, `named_type`, `interface`, `entity.root`/`parent`/`ancestors`/`admitted`/`concrete`, `origin`/`declared_by`, and the `object`/`relationship`/`contributor` of every flattened listing — and every `_href` is built from it (`/reflect/objects/core:Contact`; `:` needs no escaping in a path segment). Member names (attributes, traversals, lifecycles, actions), element paths (`Contact.validators.check`), a `type` expression, policy text and physical names (`table`, `id_prefix`) are not element names and keep their authored form. Inputs accept either form: `{Object}`, `{Name}` and a UI route's `{object}` take `Contact` or `core:Contact`, and a full name in the wrong namespace answers `404`. In v0 a short name always names exactly one element of its kind, so the short form is never ambiguous.

**`kind` discipline (`BSL-REQ-37`).** Every reflected object or relationship node carries `kind`: `"object"` or `"relationship"` — so the two constructs' distinct wire semantics (an object's `attributes` vs. a relationship's `from`/`to`/`properties`) are never ambiguous from the payload shape alone. An endpoint object's own `relationships.outbound`/`.inbound` entries additionally distinguish provenance: an entry derived from a `ref` attribute stays exactly as it always has (no `declared` key, `_href` to the *other object's* scope), while an entry derived from a declared `relationships:` link carries `declared: true` and an `_href` into `/reflect/relationships/{Name}` instead (see "Relationships" above).

**No stub scopes remain.** `lifecycles`/`actions` went live in M4 (see "Actions"); `invariants` — the last `not_implemented` stub — went live in M4.1 (see "Aggregates, rollups, and invariants"). Every logic-reflect scope this document names answers a real `200` body, keyed/counted exactly like `objects`/`relationships`; none answers `501` any more. The `/ui` actor/policy context seam remains reserved for M6; raw proposals use the authenticated authority path.

**One source, one honest caveat.** Every reflection response carries the `Model`'s `model_checksum` (`sha256:...`) so you always know exactly which version you're reading. Reflection always describes the model the engine is currently holding — during the brief window between a guarded apply's DDL commit and its registry swap, that can be one version behind the physical schema, so "reflection is current" is not a guarantee this engine makes; "reflection and the checksum it reports agree" is.

## Apps from packages

A package with a UI definition, `ui/app.yaml` (exported as `{type: app, name: app}`), is an app (ADR-0070, ADR-0072); one with model elements and no UI definition is a module, one with only components a component library, one with only presentations, assets and renderer components a theme. A package is at most one app. A theme's presentation is selected by full name and contract major (`presentation: brand:brand@1`), and its image, font and stylesheet assets are `tenant:brand:<asset>` in tenant UI. Its presentation may list its own stylesheets (`stylesheets: [brand:theme]`), which the console loads into the `presentation` cascade layer while that presentation is selected; a theme ships no loose scripts. Only an app that lists the theme package directly sees its presentations and assets (`package_not_listed` otherwise); a theme's renderer components resolve for any consumer that depends on the package, direct or transitive, without being listed under `use:` — an app never lists a theme's renderers, only its own content components. A renderer cannot be placed on a page (`renderer_not_placeable`); only a theme's `renderers:` may name one.

### Installing an app

Installing a whole app means writing three things, and nothing else — everything the app itself carries is visible in the install plan before it happens:

1. **A dependency line** in `basel.package.yaml`/`basel.yaml` naming the package and a semver range. Cycles are refused; `basel build` validates a package against the lowest and the newest version each range allows, so a range can never claim a version the package does not work with; `basel upgrade` resolves the whole tree together, one version per package per tenant.
2. **A role assignment per person.** A package ships roles, permits and assertions granting only on its own elements, and never ships memberships — the tenant assigns people to a package's roles, or includes them in its own. An app's role may `include` a dependency's role (`sales:rep` includes `crm:contact_user`), so installing an app still costs one role per person even when it draws on several packages. An upgrade that widens a role's effective grants (a new object or action grant, a wider read or write field ceiling, a change reached through `includes:`) is flagged and needs approval before it applies; removing a role that people hold is refused, naming the count.
3. **Approvals, only if the app asks for elevated reads** — see below.

### Namespaces, app routes, and `?app=`

A package's namespace is its app's identity and URL segment: `/{tenant}/apps/{namespace}` opens it. A package namespace equal to the tenant's own `app:` name is refused (`namespace_is_app_name`); an app whose release has no `title:` is refused (`missing_app_title`) — that title is the app's name everywhere; an app with no namespace is refused (`app_without_namespace`). `GET /ui/app` lists every installed app — `namespace`, `title`, `route`, `audience` — for a launcher; an app's `audience:` roles only limit that listing, never access to the app itself.

Every `/ui/*` GET door, and `POST /tenants/{key}/ui/queries/{id}`, take an optional `?app={namespace}` query parameter, defaulting to the tenant's own app; the checksum and cursor an app-scoped call returns are that app's own. Naming an unknown app refuses. An object's default view (`ui/objects`) belongs to the object's owner; an app may map one of its objects to its own record page instead with `record_pages: {<object>: <page>}`, without disturbing any other app's view of that same object — a page in a package with no UI definition is refused (`page_without_app`), and an object view naming an object the package does not own is refused (`object_view_not_owned`; naming one it does own, `object_view_owned` marks the case that resolves). An object's generated list is the same default, and `list_pages: {<object>: <page>}` is `record_pages`' twin: it maps the object's *list* instead — a different surface mode, so an app may map one object through both at once — on the same terms, naming an object outside the app or an unknown page `unknown_list_page_object`/`unknown_list_page`; `record_pages` and `list_pages` draw from the same pool of the app's own pages, so a page either already claimed is unavailable to the other.

**Where everything comes from (BASEL-248..250).** Beyond the elements above, reflection gives a `provenance` of the same shape to every UI page and object view (`/reflect/ui` and its `objects/` and `pages/` doors), to an app's UI definition (`definition`), to every lifecycle (`/reflect/lifecycles`: its object's provenance, which declares it in the same file), and to every role and permit (`/reflect/authority`, and each permit in `/authz/declared_grant_matrix`: the owner's `policies/roles.yaml` for a role or a role's `objects:` shorthand, its `policies/<object>.yaml` for a permit). A tenant-owned one is `{namespace: core, path}`, naming the tenant's own file; a built-in role is `{namespace: basel}`. An object view the app does not author is `generated: true`, and its provenance is its object's, which it is derived from. `/reflect/ui` and its two doors take `?app={namespace}` like the `/ui/*` doors and answer for that app. Its `pages` — and `/ui/pages` — also list the pages an app places through `record_pages`/`list_pages`, each marked `placed: {record: <object>}` or `{list: <object>}`, beside the routed ones. Each package in `/reflect/packages` lists what it `contributes`: `objects`, `relationships`, `queries`, `functions`, added `fields`, `roles`, `permits` (by id), `lifecycles` and `formulas`/`rollups` (as `<object>.<name>`), its app's UI definition (`app`: `{namespace, source}`, or `null`), `pages` (routed and placed), `object_views`, and the `presentations`, `components` and `assets` vendored from it.

**Who may open an app's UI.** The `/ui/**` catalog needs `metadata.read`, and a package role may not grant a tenant action, so an installed app's own surfaces — every `/ui/*` request carrying `?app={namespace}` for an installed app package — also admit anyone holding a role that app's package owns, directly or through any role whose `includes:` reaches one (`seller` including `sales:rep`). That opens only that app's surfaces: not the tenant's own UI (no `app`, or the tenant's own app name), not another app's, and not `/reflect/**`. A caller admitted this way (rather than through `metadata.read`) sees every field list — an object view's `fields`, field placements, generated views and forms, related-list columns, record pages, query contracts' `select` — narrowed to their own read ceiling, so a field they cannot read is not listed by name, and the launcher's `apps` lists only the apps they can open; such a document is served `Cache-Control: private`. Data is filtered by the caller's own read policy either way. A generated list query (`POST /ui/queries/object.<Object>.generated.list`, generated related lists included) leaves out the columns, default sort terms and sortable columns the caller cannot read, for any caller, instead of refusing; a dropped default sort pages by `id`, and a refusal that remains (a caller-supplied sort on a hidden field) names only what the caller sent. Hand-written queries still refuse `unreadable_attribute`.

### Elevated reads and approvals

An action or transition may declare `elevated: {reads: {"crm:Contact": [email]}}` (a sibling of `function:`) to read named fields of a dependency's object beyond what the invoking user could otherwise see. It runs only while the tenant's `basel.yaml` carries a matching approval, keyed by the exact element the elevated read attaches to — an action as `"Deal.check_duplicate"`, a lifecycle transition as `"Deal.stage.close"` — never a bare function or action name, which is refused (`invalid_approval`): `approve: {"@acme/crm-sales": {"Deal.check_duplicate": {reads: {"crm:Contact": [email]}}}}`. The approval must list at least the requested reads, checked on every invocation: widen the request in a new version, or revoke the approval, and the action refuses (403) until the approval catches up. An elevated read always needs an invoking user — it never runs on an engine-triggered invocation with no caller. What it can name is narrow: never the package's own data, never the tenant's elements, never `*`, and never a write (`invalid_elevated`); one approved read discloses every row of the objects it names to whoever may invoke the action, and the plan says so. Each run is audited `function.elevated`; a new or widened elevated read is flagged for the tenant's approval under the existing `function.admit_scope` action rather than a new one, so admin role checksums stay stable across this change; an approval nothing asks for is a warning (`approval_unused`), and the install plan carries `function_reach`/`elevated_read` advisories for what each package function reaches outside its own package. `/tenants/{key}/reflect/functions`'s `elevated_reads` names the admitting principal and plan, folded into its ETag.

### Adding to a package's object (`additions/`)

The tenant may add to an object of a package its `basel.yaml` lists directly (ADR-0071), in `additions/<namespace>/<object>.yaml` — `additions/crm/contact.yaml` holding `object: Contact` — using the object file's own keys, limited to `attributes:` (optional, or with a `default:`; a required one on a populated table also needs `backfill:`), `formulas:` (read-time), `rollups:` (each writes its own output field, which is the tenant's), `guards:` and `invariants:`. Refused: any other key (`addition_key_not_allowed`; `validators:` and `locks:` are deferred), a required field with no default (`addition_field_required`), a `ref`, `set` or `reference_choice` field (`addition_field_ref`), an object that extends or is extended (`addition_on_family_member`), an object its package declares `extensible: false` (`object_not_extensible`), an unlisted package (`package_not_listed`), and a name that is not a package object (`unknown_object`). A package may not ship `additions/` (`package_ships_additions`).

**Naming.** A name is qualified by its owner only where it sits on something another owner owns. Everything the tenant adds to a package object is `core:<name>` — `core:customer_tier` in BQL paths, record JSON keys, permits' and roles' field lists, forms, UI query ids and reflection — stored in the column `core__customer_tier`. It is declared short in the additions file and referenced qualified everywhere else, including that file's own guards and formulas; the short name is not accepted at any door. Relationship ends follow the same rule: an end one owner's relationship puts on another owner's object is qualified by the declarer (a tenant relationship to `crm:Contact` gives `Contact.core:notes`; crm-sales' gives `Contact.sales:deals`, and the permit action `relationship.sales:deals.reference`), while ends on the declarer's own objects and on built-ins stay short. Ends are authored short; writing `core:notes` in a relationship file is refused (`invalid_identifier`). A package field and the tenant's addition with the same short name coexist; only a new addition shadowing a field the package already has is refused at push (`addition_shadows_package_field`). Clients must accept `:` in field keys, paths and query ids.

**Authority.** An addition is governed by the tenant's roles and field ceilings; a package role's `fields: ["*"]` never covers it, and no package's code sees, writes or names it (a package function `scope:` or module contract naming one is refused at push, `package_foreign_element`; a package validator skips an update that changed only additions). The tenant's guards and invariants on a package object run on every write to it, the package's own actions and functions included, and are judged as the tenant's; a refusal names the rule by its qualified element (`Contact.core:gold_keeps_name`) and carries an additive `"owner": "tenant"`, at the top level and on its `errors[]` entry, so a failing package action points at the tenant. The plan carries a `tenant_rule_on_package_object` advisory for each such object on install, upgrade and any rule change. A tenant rollup's recompute changes the carrier's version (a client holding the old version gets a conflict), without a history entry, as for every rollup. A tenant permit that filters on an addition still decides which rows a package function borrowing the user's permissions sees.

**Where additions show and what they pin.** Generated views and forms list the package's fields, then the additions in authored order, then added formulas; an app's hand-built page shows only what it names. Reflection gives each added element `owner: "tenant"`, namespace `core` and its `additions/` file as provenance, while the object keeps the package's. An addition can't be removed yet (`removal_not_supported`), and it pins its package: removing the package, or upgrading to a release without the object, is refused while the file exists (`unknown_object`, `package_not_listed`, or `addition_needs_package` when the addition reaches another package, as a rollup over `sales:deals` does) and after it is gone (`package_object_has_additions`). A package may close an object with `extensible: false`; closing an open one in a minor or patch release is a break (`object_closed_to_extension`).

### What a package's code can and cannot see

A package function's own-data privilege reaches only its own objects, and only the fields its own package added to them — never a field another owner (the tenant, or another package) added to the same object, whether through a wildcard, a whole-row read, or a write payload. Reading a dependency's exported objects is declared in the action's `scope:` (full names only — `crm:Contact`, never `Contact`; the wrong namespace refuses `wrong_namespace`, a short name for a dependency's element refuses `unqualified_dependency_reference`) and filtered by the invoking user's own read policy on every read door — `get`, a traversal, a query, a count, a named query. Writing to a dependency's objects, including consequential writes made by an action's own effects, is judged with the user's own permits, never the action's declared grant. A field another owner added to an object (the tenant's `additions/`, below) is hidden from the package's code on every object, its own or a dependency's, for reads and writes, even when the user could read it and even under an approved elevated read. Concealment is probed before authority: a reference or a direct read/update/delete/transition naming a row the invoking user cannot see answers not found, exactly as if the row did not exist. A function's own filter on a field it cannot see fails the whole invocation as a scope violation; a permit or guard condition reaching the same hidden data instead lowers that one step to `FALSE` — never `NULL` — and keeps evaluating, which is why `NOT`/ `IS NULL` can never be used to probe for a hidden row's existence, while an ordinary empty hop (nothing there at all) keeps its usual `NULL` meaning. Formulas, stored rollups, validators, invariants and declared effects may not reach a dependency's data at all in v1 — they run with no user permissions to borrow. Everything a package ships is public; nothing about what code the tenant installed limits what that code can see beyond these rules.

## Tenants

| Key | Reflect |
|---|---|
| `slate_next` | `/tenants/slate_next/reflect` |
| `twoapps221` | `/tenants/twoapps221/reflect` |
| `agent_sandbox` | `/tenants/agent_sandbox/reflect` |
| `petstore` | `/tenants/petstore/reflect` |
| `lawfirm` | `/tenants/lawfirm/reflect` |
| `crm_foundation_spike` | `/tenants/crm_foundation_spike/reflect` |
| `crm_foundation` | `/tenants/crm_foundation/reflect` |
| `crm_essentials` | `/tenants/crm_essentials/reflect` |

