cratefield-tables-api 0.1.2

The HTTP API over a venture's declared tables: who may reach which rows, and the routes that serve them
Documentation
# cratefield-tables-api

The HTTP API over the tables a venture declares in its manifest's
`[tables]` section (issue #153), with the access level each one declares.

**Not a module crate**, which is why it is not named like one. A
`crates/module-*` crate provides a `Module` and the conformance kit runs
against every one of them. This crate provides what a venture's
*generated* tables module calls — that module has to be generated source,
because `personal_data()` is `&'static` all the way down and cannot be
assembled from a manifest read at runtime.

## What is here today

The **access decision**: given a declared table, the caller the `Auth`
port identified, and the outcome of the admin check, which rows this
request reaches.

```rust
match may_read(&api, tenancy, &caller, admin)? {
    Reach::Everything => /* the whole table */,
    Reach::OwnedBy { column, subject } => /* rows where column = subject */,
}
```

It is a function of values with no HTTP in it, which is deliberate: it is
the part that decides who sees whose rows, so every case is a test that
reads as a sentence rather than as a request.

## Reading

`page` and `one` apply that decision against a database. Both carry the
scope into the `WHERE` rather than filtering rows already fetched: a
filter applied afterwards turns a `LIMIT 50` into a page of however many
survived, and the shortfall is a count of the rows the caller was not
allowed to see.

They take a `&dyn Database`, and the routes hand them a `TenantConn` —
never `ctx.ports.db`, which is the same handle for every request whoever
asked. Taking the trait is what lets them be exercised against a real
database without an HTTP stack; `TenantConn` has no constructor, which is
the point of it.

| answer | when |
|---|---|
| `404 no-such-table` | the venture declares no table by that name |
| `404 no-such-row` | there is no such row **or** it is not the caller's |
| `401 unauthenticated` | no credential where one is needed, or one that did not verify |
| `503 verifier-unavailable` | a credential was presented and could not be checked |
| `500 table-misdeclared` | `owner` with no subject column to match against |
| `500 no-membership-fact` | `tenant-members` where a tenant registry named the tenant — the level is refused to every caller |

The two 404s are the same answer on purpose.

## The routes

| route | answers |
|---|---|
| `GET /{table}?after=&<column>=` | a page of rows, and `next` when there is another |
| `POST /{table}` | `201` and the row it created |
| `GET /{table}/{key}` | one row by its primary key |
| `PUT /{table}/{key}` | `200` and the row it replaced |
| `DELETE /{table}/{key}` | `204`, and nothing |
| `GET /{table}/__by?<column>=<value>&…` | one row by its primary key, named in the query |
| `PUT /{table}/__by?…` | `200` and the row it replaced |
| `DELETE /{table}/__by?…` | `204`, and nothing |

**`/{table}/{key}` needs a key of one column.** It refuses a composite
key rather than joining its values with a separator that could occur
inside one (`400 composite-key`), so a table declaring `primary_key =
["tenant", "member"]` cannot name a row through it — and its refusal
names the route that does: `/{table}/__by?<column>=<value>&…`, the key
named in the query (ADR 0018). The `__by` routes answer with the same
bodies and statuses the path routes answer — a read returns the row, a
replace takes the row and answers the replaced one, a remove answers
`204` and nothing — one route whatever the key's arity.

`__by` answers for **every** declared table, not only composite-key
ones, and not as a second way to do one thing: a static segment beats
the dynamic `{key}` in the one shared router, so on a single-column-key
table a row whose key value is literally `__by` would otherwise be
unreachable, and `/{table}/__by?id=__by` is that row's address back.

The `__by` query names primary-key columns and nothing else. Naming some
but not all is `400 partial-key` — a row is addressed by its whole key,
not by every row sharing a prefix. Naming a column outside the key is
`400 not-a-key-column`, because ignoring it answers a question the
caller did not ask. Values are parsed exactly as a path segment's, so a
`real`, `boolean` or `json` key stays refused with `bad-key`. `after`
and `sort` are the harness's parameters on this sub-path and can never
name a key column, so a table whose key uses one of those names has no
address here.

For a readable table the surface publishes the page and the one row,
and three more for a writable one, each single-row action against
whichever spelling its table's key calls for — and
`every_published_action_is_a_route_that_exists` is what keeps the two
lists the same. A published action whose route does not exist is worse
than an unpublished one: a generated UI renders the form and the
submission 405s.

**A `409` means the key is taken and nothing else.** The `Database` port
has no typed constraint error, so a collision is recognised by the two
engines' own phrases — `UNIQUE constraint failed` and `duplicate key
value violates unique constraint` — and never by the bare word `unique`,
which also matches the *table's name*. A venture with a table called
`unique_codes` had every database failure on it answered `409`,
including `no such table` after a half-applied migration: the caller is
told the key is taken, picks another, and is told the same. Anything the
phrases do not match is the generic failure, which is the safe
direction — a `500` where a `409` was due costs a retry.

A create answers `201` rather than `200`, because a create that answers
`200` is indistinguishable from an update to a client watching status
codes. A delete answers `204` with no body: there is nothing left to
describe, and inventing one (`"deleted": true`) is a second thing to keep
true.

Mounted under the generated module's name, so a venture's `note` table is
at `/v1/tables/note`.

This is the layer that insists on a `TenantConn`. `page` and `one` take
the trait so they can be exercised without an HTTP stack; the extractor
can only hand back the handle the resolution layer resolved for *this*
request's tenant.

A key in a path is one segment, so a table whose primary key is more than
one column is refused (`400 composite-key`) rather than addressed through
an invented separator — which would make a key containing that separator
unaddressable, silently, and only for the rows that contain it. A `real`,
`boolean` or `json` key is refused too: a float compared with `=` is a key
that sometimes matches nothing, for reasons the caller cannot see.

## Paging

`next` is the cursor, and `?after=` takes it back **exactly as it was
given** — the same JSON, percent-encoded. One shape, because two would
mean every client carries the translation between them, and the only
place that knowledge exists is here.

It is not parsed by `key_from_path`. That exists for a *path segment*,
which can carry one value, so it refuses a composite key — and refusing
there made a composite-key table unpageable past its first page over
HTTP, while `select_page` could express the query perfectly well.

A cursor that is not JSON, is not an object, omits a key column, or names
one with a value that is not its kind, is a `400 bad-cursor` that says
which.

## Several reads in one request

`POST /{mount}/__batch` with `{"reads":[{"table":"note","where":{...},"after":...}]}`
answers them in the order they were asked, at most 20 per batch.

What it buys is **one round trip**. Not parallelism: the reads run in
sequence against the request's one database handle, because that is what
a handle is. Claiming otherwise would have a caller sizing their batches
by the wrong number.

**A batch cannot ask for what the caller could not ask alone.** Each read
is decided on its own, against the same access level it would face
singly.

**One refused read refuses the whole batch**, naming which. A `200`
carrying a refusal per result is a success that is not one, and every
client would have to remember to look inside it.

`__batch` can never collide with a declared table: a table name starts
with a lowercase letter and may not contain `__`. The route is registered
first anyway — "cannot collide" is a fact about a validator somewhere
else — and a test asserts the name is not a legal identifier.

## Ordering a page

`?sort=column` or `?sort=-column`, one column. A second is a tiebreaker
and the primary key is already that — and every column a page is ordered
by has to appear in the cursor, so the vocabulary that stays small is the
one whose cursor stays readable.

The key always follows the sort column in the same direction, so two rows
with the same value cannot land in an order that changes between pages.
The cursor names both, and a cursor short of either is `400 bad-cursor`;
a column that is not declared, or is optional, is `400 bad-sort`.

An optional column cannot be sorted by at all: SQLite sorts `NULL` first
and Postgres sorts it last, so the page would differ between two
deployments of one declaration.

A batch read takes `"sort"` too, or the batch is a second-class way to
ask the same question.

## Narrowing a page

Every query parameter but `after` is a filter, named for the column it
narrows. **Equality, and nothing else.** #153's rule bounds the
declaration surface — *anything referencing another row or another
request is a function, not a field* — and the same instinct bounds what a
caller may ask of one: ranges, prefixes and `LIKE` are queries a module
writes, not vocabulary a manifest grows into.

A parameter naming a column the table does not declare is a `400`, not a
parameter ignored. Ignoring it answers a question the caller did not ask,
with more rows than they asked for — and a client that misspells a column
would get a page that looks right. The value is checked against the
column's kind for the same reason.

**A `null` asks `IS NULL`.** Nothing equals null in SQL, itself included,
so comparing against a bound null is false for every row: a caller asking
for the rows whose `body` is unset was answered an empty page whatever
the table held. Only a batch read can ask it — a query string is text, so
`?body=` is the empty string and there is no spelling of null in one,
which is why the path that could express the question is the one with no
way to say it.

**A `json` column is not filterable.** It is stored as its
serialization, so equality compares text whose bytes depend on the order
the keys were written in — `{"tier":"gold","since":2026}` and
`{"since":2026,"tier":"gold"}` are one value and two different things to
compare against. Reading inside a JSON column is a query a module or a
sidecar writes, which is #153's rule again. Asking whether one is
**unset** still works: that is a question about the column, not about
what is inside it.

**A filter cannot widen an `owner` scope.** The subject condition and the
filters are all in the same `WHERE`, so filtering on the subject column
narrows the caller's own rows and reaches nobody else's.

## Writing

`create`, `replace` and `remove`. `may_write` is a **separate** decision
from `may_read`, because `public-read` reads for everybody and writes for
nobody — one function answering both would need a parameter saying which,
and the day somebody passes the wrong one a public table becomes
writable.

| level | may write |
|---|---|
| `public-read` | **nobody** (`403 table-read-only`) |
| `tenant-members` | any signed-in caller, any row; with a tenant registry, nobody |
| `owner` | a signed-in caller, their own rows |
| `admin` | an admin token |

**`tenant-members` means any verified caller, not a member of this
tenant.** There is no membership fact to check: a `Caller` carries an id,
a session and an address, and `Ports::tenants` is not a `Port`, so a
module cannot ask which tenant it is serving either. On a deployment
without a registry — every venture `fz build` generates — the two are the
same set, because there is one tenant, and the level serves as it always
did. On one with a registry they are not, and the level is refused with
`500 no-membership-fact` at every host, the caller's own included: "any
verified caller" is a wider set than "a member of this tenant", and
serving the first as the second is the leak of issue #385. The refusal
is a `500` and not a `403` — nothing the caller did is wrong and no
credential of theirs fixes it — and it is not a membership check. #385
stays open for the fact that would make one, a tenant claim on the
subject.

**A row a caller writes is a row they own.** Under `owner` the subject
column is settled by the harness, not taken from the body: absent or null
is filled in with the caller's id, already theirs is left alone, and
somebody else's is **refused** rather than quietly corrected. Overwriting
would be safe — the row would still be the caller's — but the client
asked for something and got something else without being told, which is
how a bug in a client becomes data nobody can explain.

**Changing or deleting a row that is not yours reports no such row.** Not
a 403, for the reason a read gives the same answer. The scope is part of
the `UPDATE`'s and `DELETE`'s `WHERE`, so the row matches nothing and
zero rows changed is the refusal.

`replace` writes every declared non-key field. A merge would make "unset
this field" unexpressible: an absent key and a null one would both have
to mean "leave it".

## What the venture publishes

`surface(&tables)` is what `/__surface` carries for the declared tables:
two actions per readable table and three more for a writable one. A table
that is served and absent from it is a venture whose published contract
is smaller than the venture.

A surface says a route exists, what it takes, and whether a credential is
needed. It does **not** say which rows the caller gets — that is decided
per request against their own id. So `owner` and `tenant-members` both
publish as `Audience::Subject`, a variant added for them: calling `owner`
public would render a form for rows the caller cannot reach, and calling
it admin would hide it from the person whose rows they are.

Nor can a surface say whether the level will be honoured. `surface()`
reads the manifest and nothing else, so a `tenant-members` table
publishes as `Audience::Subject` on a registry deployment too — one where
every request to it will be refused. Closing that needs the deployment's
tenancy threaded through `Module::surface`, which does not carry it
today; #385 is where the published contract and the served one come
apart.

A composite-key table publishes all five actions, its three single-row
ones against `/{table}/__by` (ADR 0018) — the same rule as ever, that
the contract lists what is there. Without it, a generated
client carries methods that answer `403` or `400` whatever they are
called with.

Where the key travels is a fact about the key's arity, and the
published path says which: `/{table}/{key}` for a key of one column,
`/{table}/__by` for a wider one, where the path carries no placeholder
and the published schema names the key columns instead — as the input
of a read and a delete, which have no body, and beside a replace's row
body under `x-cf-query`. That keyword is introduced by this crate,
riding under the harness's `x-cf-*` namespace: a client generator can
tell a path parameter from query parameters, and one that does not know
the keyword ignores it the way it ignores every `x-cf-*` keyword it was
built before.

A `public-read` table publishes its reads and no writes, because there
are none. The body schema on a write is the table's own JSON Schema — the
same bytes the row validator enforces, so a generated form and the route
it posts to cannot disagree about what a row is.

## What a declared table's privacy block actually does

`tests/privacy.rs` mounts a declared-tables module beside
`cratefield-module-privacy` and asks the questions a subject would:

- a subject's `/v1/privacy/export` carries their rows from the declared
  table, and the sentence the author wrote is published with them;
- a table declared to hold nobody is in no subject's export;
- `/v1/privacy/manifest` names both, and publishes the reason the second
  holds nobody;
- an erasure removes the subject's rows and leaves another subject's
  alone;
- and leaves the table that holds nobody alone, which is the failure that
  would delete a venture's reference data the first time anybody asked.

A declaration that never reaches an export is decoration, and it is
decoration that reads as compliance. These are what make it not that.

## The rules, and why each is that way

| level | anonymous | signed in | reaches |
|---|---|---|---|
| `public-read` | yes | yes | everything |
| `tenant-members` | **401** | yes | everything, where there is no tenant registry |
| `tenant-members`, registry deployment | **500** | **500** | nothing — every host, the caller's own included |
| `owner` | **401** | yes | only rows whose subject column is theirs |
| `admin` | the admin check's own answer | same | everything |

**An anonymous caller gets 401, not an empty page.** "No rows" and "you
are not signed in" are different answers and only one of them tells the
caller what to do about it.

**`owner` is a scope, not a filter.** The subject condition joins the
`WHERE`; it is not applied to rows already fetched. A post-filter paired
with a `LIMIT` hands back a short page, and the shortfall is a count of
rows the caller was not allowed to see.

**A row belonging to somebody else is not found, not forbidden.** A `403`
is an answer about a row the caller was never in a position to learn
exists. The scope produces that by construction: the subject condition
and the key are in the same `WHERE`, so the row does not match.

**`admin` keeps the admin check's own refusal.** "Admin endpoints are
disabled or you sent no token" and "the token you sent is wrong" are
different facts — a 401 and a 403. Re-deciding them here collapses them.

**`owner` on a table with no subject column refuses with a 500.**
`fz build` will not produce such a manifest, so reaching it means the
deployment is running a composition its manifest would not have made.
With no column to match against, "everything" and "nothing" are both
wrong and one of them is a leak. The column is checked against the
table's fields at request time as well as at build time, because a table
edited under a stale privacy block would otherwise produce
`WHERE nope = 'ada'`.

**`tenant-members` on a deployment with a tenant registry refuses with
a 500, to everybody.** The same shape of fault as `table-misdeclared`:
the deployment's composition, not the caller's doing. There is no
membership fact (issue #385), and where a registry named the tenant
"any verified caller" and "a member of this tenant" are different sets,
so the level is refused at every host — the caller's own included —
rather than served to whoever arrives. Signing in does not help; a
different credential is a caller the deployment equally cannot place.

**A level this build does not understand is refused.** `Access` is
`#[non_exhaustive]`; a level added later arrives at that arm rather than
falling into one of the four above, and the safe answer is no.