mirror of
https://github.com/simonw/datasette.git
synced 2026-07-09 00:54:35 +02:00
Invalid dstok_ tokens - bad signature, malformed payload, expired, or presented while allow_signed_tokens is off - previously degraded the request to anonymous, so clients saw a 403 permission error or worse, a 200 with anonymous-visible data. Token handlers can now raise TokenInvalid for tokens they recognize but reject; Datasette responds with 401, the canonical JSON error body and a WWW-Authenticate: Bearer error="invalid_token" header, even when a valid cookie is also present. Bearer tokens no registered handler recognizes are still ignored, so authentication plugins with their own token formats keep working. TokenInvalid is exported from the datasette package for use by plugin token handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
407 lines
21 KiB
Markdown
407 lines
21 KiB
Markdown
# Datasette 1.0 Stable API — Consistency and Completeness Review
|
||
|
||
This review is based on `existing-api.md`, which documents the JSON API as
|
||
actually implemented in this codebase (`1.0a35`), derived from source. The
|
||
goal here is to identify everything that should be made consistent, fixed, or
|
||
explicitly scoped out **before** the 1.0 stability promise takes effect —
|
||
because after 1.0, every inconsistency below becomes a compatibility
|
||
commitment.
|
||
|
||
Findings are grouped by theme. Each carries a priority:
|
||
|
||
- **P1 — should block 1.0**: breaking to fix later, or a correctness/security
|
||
concern.
|
||
- **P2 — strongly recommended**: fixable later only via awkward additive
|
||
changes.
|
||
- **P3 — nice to have / documentation decision**: can be resolved by
|
||
documenting the behavior as intentional.
|
||
|
||
---
|
||
|
||
## 1. Error responses: four shapes is three too many (P1) — ✅ IMPLEMENTED
|
||
|
||
> **Status:** implemented. All four shapes now delegate to a shared
|
||
> `error_body()` helper (`datasette/utils/__init__.py`) producing
|
||
> `{"ok": false, "error": "<joined>", "errors": [...], "status": <int>}`.
|
||
> The `title` key is no longer emitted in JSON; the bare `{"error": ...}`
|
||
> debug-endpoint shape is gone; `_shape=object` misuse now returns HTTP 400
|
||
> (part of §1b). Covered by `tests/test_error_shape.py` and documented in
|
||
> the "Error responses" section of `docs/json_api.rst`. §1a (`Forbidden` →
|
||
> JSON) and §1b (write canned-query 200) are now also implemented. Still
|
||
> open from this section's sub-items: the §1c status outliers.
|
||
|
||
The API currently produces four distinct JSON error shapes depending on which
|
||
internal layer generates the error:
|
||
|
||
| Shape | Producer | Example endpoints |
|
||
|---|---|---|
|
||
| `{"ok": false, "error", "status", "title"}` | exception handler (handle_exception.py:50-53) | 404s and `DatasetteError`s on any `.json` path |
|
||
| `{"ok": false, "errors": [...]}` | `_error()` helper (views/base.py:183-184) | all write endpoints, stored-query endpoints, execute-write |
|
||
| `{"ok": false, "error", "rows": [], "truncated": false}` | JSON renderer (renderer.py:52-56) | SQL errors on table/query reads |
|
||
| `{"error": "..."}` (no `ok`) | permission debug views (views/special.py) | `/-/allowed`, `/-/rules`, `/-/check`, POST `/-/permissions` |
|
||
|
||
Additionally, write canned queries report failure via a **fifth** vocabulary:
|
||
`{"ok": false, "message": ..., "redirect": ...}` with HTTP **200**
|
||
(views/database.py:678-690).
|
||
|
||
A 1.0 client cannot write a single error handler today. **Recommendation:**
|
||
pick one canonical error object — the singular/plural tension is easiest to
|
||
resolve as:
|
||
|
||
```json
|
||
{"ok": false, "error": "human-readable summary", "errors": ["detail", "..."], "status": 400}
|
||
```
|
||
|
||
where `errors` is optional and `error` is always present — and route every
|
||
error path through it (including the `forbidden` and `handle_exception`
|
||
defaults). At minimum, eliminate the bare `{"error": ...}` shape and the
|
||
`status`/`title` keys nobody else emits (`title` is a template-rendering
|
||
concern that leaked into the API).
|
||
|
||
### 1a. `Forbidden` returns an HTML 403 to JSON clients (P1) — ✅ IMPLEMENTED
|
||
|
||
> **Status:** implemented — the default `forbidden()` hook now returns the
|
||
> canonical JSON error for requests whose path ends in `.json` or that send
|
||
> `Accept: application/json` / `Content-Type: application/json`.
|
||
|
||
Read endpoints that deny access via `ensure_permission`/`check_visibility`
|
||
raise `Forbidden`, and the default `forbidden()` hook renders an **HTML error
|
||
page even for `.json` requests** (forbidden.py:4-19, app.py:2895-2904). So:
|
||
|
||
- `GET /db/table.json` without `view-table` → 403 **HTML**
|
||
- `POST /db/table/-/insert` without `insert-row` → 403 **JSON**
|
||
|
||
A JSON client gets unparseable output precisely when it most needs a
|
||
machine-readable answer. **Recommendation:** the default forbidden handler
|
||
must return the canonical JSON error when the path ends in `.json` or the
|
||
request prefers JSON, mirroring `handle_exception`.
|
||
|
||
### 1b. Errors that return HTTP 200 (P1) — ✅ IMPLEMENTED
|
||
|
||
> **Status:** implemented. `_shape=object` misuse returns 400 (done with
|
||
> §1), and write canned-query SQL failures now return **400** with the
|
||
> canonical error shape (plus the `redirect` context key); the
|
||
> `QueryWriteRejected` 403 branch also uses the canonical shape.
|
||
|
||
- `_shape=object` on a query or pk-less table → `{"ok": false, "error":
|
||
"_shape=object is only available on tables"}` with **200**
|
||
(renderer.py:73-90), while an unknown `_shape` value returns **400**
|
||
(renderer.py:101-108). Same class of error, different status.
|
||
- Write canned-query SQL failure → **200** `{"ok": false, "message": ...}`
|
||
(views/database.py:683-690), while the equivalent failure on
|
||
`/-/execute-write` returns **400**.
|
||
|
||
**Recommendation:** all `ok: false` responses should carry a 4xx/5xx status.
|
||
(`/-/execute-write/analyze` returning `ok: false` with 200 for "analysis
|
||
completed, SQL is invalid" is defensible but should then not reuse the `ok`
|
||
key — see §2.)
|
||
|
||
### 1c. Wrong-status outliers (P2) — ✅ IMPLEMENTED
|
||
|
||
- ~~Row **delete** write failures return **500** (views/row.py:757) while row
|
||
**update** write failures return **400** (views/row.py:832-835). Same
|
||
failure class, different status; pick 400 (or 409 for constraint
|
||
violations) for both.~~ ✅ **Done** — delete now returns 400, matching
|
||
update and the rest of the write API.
|
||
- ~~Invalid or expired bearer tokens silently degrade the request to anonymous,
|
||
so clients see a 403 permission error (or worse, anonymous-permitted data)
|
||
rather than a 401 (tokens.py:147-193). For 1.0, a malformed/expired
|
||
`Authorization: Bearer dstok_...` header should produce **401** with a
|
||
distinguishable error, so clients can tell "renew your token" apart from
|
||
"you lack permission".~~ ✅ **Done** — token handlers can raise
|
||
`TokenInvalid`; Datasette responds 401 with the canonical body and a
|
||
`WWW-Authenticate: Bearer error="invalid_token"` header. Unrecognized
|
||
token prefixes still fall through to anonymous so auth plugins keep
|
||
working.
|
||
|
||
---
|
||
|
||
## 2. Success envelope: `ok` is not universal, arrays are not extensible (P1/P2) — ✅ IMPLEMENTED (§2a-2c open)
|
||
|
||
> **Status:** recommendations 1-3 are implemented. Every JSON-object
|
||
> success response now includes `"ok": true` (`JsonDataView` injects it for
|
||
> dict responses; homepage, jump, schema, permission-debug and autocomplete
|
||
> views set it explicitly), and the three top-level-array endpoints now
|
||
> return objects: `/-/plugins` → `{"ok": true, "plugins": [...]}`,
|
||
> `/-/databases` → `{"ok": true, "databases": [...]}`, `/-/actions` →
|
||
> `{"ok": true, "actions": [...]}`. Covered by
|
||
> `tests/test_success_envelope.py`. The sub-findings §2a (collection
|
||
> representations), §2b (`_extra`/`_shape` coverage) and §2c (count
|
||
> truncation) remain open.
|
||
|
||
Endpoints disagree about the success envelope:
|
||
|
||
- **Have `ok: true`:** table/row/query reads, database view, all write
|
||
endpoints, stored-query endpoints, `/-/allowed`-style debug data.
|
||
- **No `ok` key:** `/-/versions`, `/-/settings`, `/-/config`, `/-/threads`,
|
||
`/-/actor`, `/-/jump`, `/-/schema` variants (`{"database", "schema"}`,
|
||
`{"schemas": [...]}`), table `/-/schema.json`, `/-/autocomplete`
|
||
(`{"rows": []}`), homepage `/.json`.
|
||
- **Top-level JSON arrays:** `/-/plugins`, `/-/databases`, `/-/actions`
|
||
(app.py:2247-2304). A top-level array can never grow a sibling key
|
||
(pagination, warnings, `ok`) without a breaking change.
|
||
|
||
**Recommendations:**
|
||
|
||
1. (P1) Wrap the three array endpoints in objects before 1.0:
|
||
`{"ok": true, "plugins": [...]}` etc. This is the single cheapest
|
||
future-proofing fix in this list.
|
||
2. (P2) Add `ok: true` to every JSON-object success response, or explicitly
|
||
document that `ok` only exists on data endpoints. Half-consistency is the
|
||
worst outcome.
|
||
3. (P2) `/db/-/schema.json` (`{"database", "schema"}`) and
|
||
`/db/table/-/schema.json` should match the envelope style of their sibling
|
||
endpoints (they are also the only data endpoints whose 404 uses the
|
||
exception shape but whose success has no `ok`).
|
||
|
||
### 2a. Collection representations disagree (P2)
|
||
|
||
- Homepage `/.json` returns `databases` as an **object keyed by name**
|
||
(index.py:147-161); `/-/databases.json` returns an **array**; the database
|
||
page returns `tables` as an array. Choose arrays-of-objects everywhere
|
||
(objects-keyed-by-name break when names need ordering or pagination).
|
||
- Insert/upsert with `return: true` respond with `rows` (plural, list); row
|
||
update with `return: true` responds with `row` (singular, object)
|
||
(views/row.py:837-844). Pick one (`rows` everywhere, even for one row,
|
||
matches the read API).
|
||
|
||
### 2b. `_extra`/`_shape` support is uneven (P2)
|
||
|
||
The extras system (`?_extra=`, scope-registered) is the 1.0 mechanism for
|
||
response shaping — but it only exists on table, row and query endpoints. The
|
||
database view builds JSON by hand and supports **neither `_extra` nor
|
||
`_shape`** (views/database.py:189-212); the homepage likewise. Either extend
|
||
extras to database/instance scope before 1.0 or document clearly that shaping
|
||
is a table/row/query feature. Also decide the contract for **unknown
|
||
`_extra` names, which are currently silently ignored** (extras.py:116-122) —
|
||
silent ignoring means typos return the default payload with no signal;
|
||
recommend a 400 or a `warnings` key.
|
||
|
||
### 2c. Count truncation is invisible in JSON (P2)
|
||
|
||
The `count` extra is computed with a `limit 10001` subquery, so `count:
|
||
10001` actually means "at least 10001" — the `count_truncated` flag exists
|
||
but only in the HTML template context, never in JSON (views/table.py:
|
||
2334-2337). Expose it (e.g. make `count` be `null` + add `count_estimate`,
|
||
or add `count_truncated` to the JSON) before clients start trusting the
|
||
number.
|
||
|
||
---
|
||
|
||
## 3. Pagination: three mechanisms, two contracts (P2)
|
||
|
||
| Endpoint | Mechanism | Token | Extras |
|
||
|---|---|---|---|
|
||
| Table `.json` | keyset | tilde-encoded pk/sort values in `_next` | `next` always in body, `next_url` via `_extra`, `Link: rel=next` header |
|
||
| SQL view `.json` | **offset** | integer in the same `_next` parameter | same envelope |
|
||
| `/-/queries` lists | keyset | cursor in `_next` | `next`, `next_url`, **`has_more`** in body |
|
||
| `/-/allowed`, `/-/rules` | **page numbers** | `page`/`page_size` | `total`, `next_url`, `previous_url` |
|
||
|
||
Concerns:
|
||
|
||
1. The same `_next` parameter means "start after key" on tables but "row
|
||
offset" on views. Offset pagination over views is also O(n) and skews
|
||
under concurrent writes. If unifiable, unify; if not, document loudly.
|
||
2. `has_more` exists on query lists but not table pages; `total` exists on
|
||
debug endpoints but not elsewhere. Standardize the pagination block
|
||
(suggest: `next`, `next_url` — nullable — everywhere; treat `has_more` as
|
||
`next != null`).
|
||
3. Page-size parameters: `_size` (default 100, `max` keyword allowed) on
|
||
tables; `_size` (default 50 JSON, clamped 1–1000, no `max` keyword) on
|
||
query lists; `page_size` (default 50, silently capped at 200) on debug
|
||
endpoints. Align names, defaults and the cap behavior (silent capping vs
|
||
400) as far as practical.
|
||
|
||
---
|
||
|
||
## 4. HTTP semantics (P2)
|
||
|
||
- **201 vs 200:** insert → 201, upsert → 200 (views/table.py:1194), create
|
||
table → 201, store query → 201. Insert-201/upsert-200 is defensible
|
||
(upsert may not create) but it is undocumented subtlety; state it, or
|
||
return 200 for both with an explicit `created` count.
|
||
- **Destructive-action confirmation is asymmetric:** table drop requires
|
||
`{"confirm": true}` and has a preview response (views/table.py:1346-1365);
|
||
row delete executes immediately and ignores the body; query delete
|
||
executes immediately. Decide the 1.0 rule (suggestion: confirmation only
|
||
for schema-destroying operations, i.e. keep as is — but document it as a
|
||
deliberate contract).
|
||
- **Content-type enforcement is inconsistent:** `/-/insert`, `/-/upsert`,
|
||
`/-/alter`, `/-/set-column-type` demand `Content-Type: application/json`
|
||
(400 otherwise); `/-/create` parses the body as JSON regardless of
|
||
content type; execute-write and the query CRUD endpoints accept both JSON
|
||
and form encodings. Pick one rule for JSON-only endpoints.
|
||
- **JSON-vs-HTML negotiation on POST differs per endpoint:** execute-write
|
||
and canned queries key off `Accept: application/json` / a `_json` body
|
||
field; the write API keys off nothing (always JSON); query store keys off
|
||
request content type. A single documented rule ("responses are JSON if the
|
||
request body was JSON or `Accept: application/json`") would cover all of
|
||
them.
|
||
- **Endpoints named like actions but served over GET:**
|
||
`/-/queries/analyze`, `/-/execute-write/analyze`,
|
||
`/-/foreign-key-suggestions`, `/-/query/parameters` are all GET (correct,
|
||
they are reads) — fine, but `analyze` under a POST-shaped path invites
|
||
wrong calls; make sure 405 responses for POST on these return the JSON 405
|
||
shape (they do only when the path ends `.json` or content type is JSON —
|
||
a JSON POST to `/-/queries/analyze` gets JSON, a form POST gets text).
|
||
|
||
---
|
||
|
||
## 5. Naming and parameter conventions (P2/P3)
|
||
|
||
- **`params` and `parameters` are duplicate keys** in every stored-query
|
||
object (stored_queries.py:55-80). Delete one before 1.0 (suggest keeping
|
||
`parameters`; the write side already accepts both on input).
|
||
- **Three names for the same concept across error/message payloads:**
|
||
`error`, `errors`, `message`. See §1.
|
||
- **Boolean query parameters have at least three grammars:** `_nl=on`,
|
||
`_labels=on/off`, `?all=1`, `is_write=1|0|true|false|t|f|yes|no|on|off`,
|
||
`_nocount=1`. Adopt one accepted set (the query-list parser at
|
||
query_helpers.py:81-94 is a good candidate) and apply it everywhere.
|
||
- ~~**`.jsono`** survives on the homepage route (identical output to `.json`)
|
||
and as a row-view redirect. Remove it at 1.0; it is pure legacy.~~
|
||
✅ Removed: the homepage routes only accept `.json` and the row-view
|
||
redirect is gone.
|
||
- **`_json` is overloaded:** on GET it is a renderer option naming a column
|
||
to parse as JSON (repeatable); on canned-query POST a `_json` body field
|
||
forces a JSON response. Two unrelated meanings for one name.
|
||
- The reserved `/-/` namespace is applied consistently across routes — this
|
||
is in good shape. The one gap: table names matching `^-$`-adjacent shapes
|
||
are protected by tilde-encoding; keep a test asserting `/-/` can never be
|
||
shadowed by user data.
|
||
|
||
---
|
||
|
||
## 6. Permissions and security consistency (P1/P2)
|
||
|
||
- ~~**(P1) `/-/databases.json` ignores per-database permissions** — it lists
|
||
every attached database (name, path on disk, size) to any actor holding
|
||
`view-instance` (app.py:2157-2169), while the homepage and every other
|
||
endpoint filter by `view-database`. On a public instance with private
|
||
databases this leaks filesystem paths and database names. Filter it, or
|
||
gate it behind `permissions-debug`.~~ ✅ **Done** — the endpoint now
|
||
filters through `allowed_resources("view-database", actor)`.
|
||
- **(P2) `/db/-/schema` checks existence before permission**
|
||
(views/special.py:1308-1317): an actor without `view-database` can
|
||
distinguish "database exists" (403) from "does not exist" (404).
|
||
Standardize on permission-check-first (as the table view does) so
|
||
unauthorized actors get a uniform response.
|
||
- **(P2) `/-/threads` exposes runtime internals** (thread idents, asyncio
|
||
task reprs including file paths) behind only `view-instance`. Consider
|
||
`permissions-debug`, alongside `/-/actions` which already requires it.
|
||
- **(P3) `/-/config` redaction is substring-based** on six key names
|
||
(app.py:2502-2505); plugins storing secrets under other names leak. Worth
|
||
a note in plugin authoring docs plus a `redact_keys` plugin hook.
|
||
- **(P3) Database-level checks on `/-/create`** (insert-row/update-row
|
||
checked against `DatabaseResource`, not the about-to-exist table —
|
||
table_create_alter.py:819-856) vs table-level checks on `/-/insert`.
|
||
Correct by necessity, but document that a token restricted to
|
||
table-level `ir` cannot use `/-/create` with rows.
|
||
|
||
---
|
||
|
||
## 7. Completeness gaps for a 1.0 JSON API (P2/P3)
|
||
|
||
1. **(P2) No JSON API to create tokens.** `/-/create-token` is an HTML form
|
||
only (`has_json_alternate = False`, form-encoded POST). Any automation
|
||
that wants to mint scoped tokens must shell out to `datasette
|
||
create-token`. An intentional JSON mode (actor-authenticated, same
|
||
restriction vocabulary) rounds out the write API story — or explicitly
|
||
document token minting as CLI/Python-only.
|
||
2. **(P2) Row JSON cannot expand foreign-key labels.** `_labels` works on
|
||
table JSON but is silently ignored on row JSON (views/row.py:445-475
|
||
expands only for HTML). Either support it or return 400 for unsupported
|
||
parameters; silent ignoring is the worst option (see also §2b on unknown
|
||
`_extra` values).
|
||
3. **(P2) No machine-readable "which write features does this instance/table
|
||
support" endpoint.** Clients must probe (`/-/insert` on an immutable
|
||
database → 403). The API explorer computes exactly this data for HTML
|
||
(views/special.py:863-990); exposing it as JSON would let clients degrade
|
||
gracefully. (`/-/allowed.json` covers the permission half already.)
|
||
4. **(P3) Table list pagination.** `/db.json` inlines all tables (with
|
||
counts) and the homepage truncates to 5 per database; a 10,000-table
|
||
database has no paginated table listing. Acceptable for 1.0 if
|
||
documented; the internal catalog tables would support a real endpoint
|
||
later.
|
||
5. **(P3) `Link: rel=next` header** exists on table JSON only. Harmless, but
|
||
either add it to the other paginated endpoints or drop it from the
|
||
contract (`Access-Control-Expose-Headers: Link` suggests it is meant to
|
||
be part of the API).
|
||
|
||
---
|
||
|
||
## 8. Behavior that looks like a bug and should be resolved before freezing
|
||
|
||
1. **Trusted queries: update is blocked, delete is not.**
|
||
`QueryUpdateView` rejects `is_trusted` queries with 403
|
||
(stored_queries.py:426-427) but `QueryDeleteView.post` never checks
|
||
`is_trusted` — an actor with `delete-query` can delete a config-defined
|
||
trusted query via the API (it will resync on restart, making the
|
||
behavior confusing rather than catastrophic). Align delete with update.
|
||
2. **GET `/db/-/query` with no `?sql=` returns 200 `{"ok": true, "rows":
|
||
[]}`** while `.csv` on the same request returns 400 `"?sql= is
|
||
required"`. The JSON behavior masks caller bugs; return 400 on both.
|
||
3. **`_shape=object` HTTP 200 error** (§1b) — almost certainly unintended.
|
||
4. ~~**Row delete 500** (§1c) — inconsistent with every sibling endpoint.~~
|
||
✅ Done — now 400.
|
||
5. **The "SQL Interrupted" error embeds an HTML fragment in the JSON `error`
|
||
value** (views/database.py:805-820). Error strings in the JSON API should
|
||
be plain text.
|
||
|
||
---
|
||
|
||
## 9. Define stability tiers explicitly (P1 — documentation, not code)
|
||
|
||
Not everything under `/-/` can or should carry a 1.0 guarantee. Recommend
|
||
shipping 1.0 with an explicit three-tier contract, per endpoint:
|
||
|
||
- **Stable (semver-protected):** table/row/query reads (`.json`, `_shape`,
|
||
`_extra` public names, filters, pagination tokens as opaque strings), the
|
||
write API (`/-/insert`, `/-/upsert`, `/-/alter`, `/-/drop`,
|
||
`/-/set-column-type`, row `/-/update`, `/-/delete`, `/-/create`,
|
||
`/-/execute-write`), stored-query CRUD + execution, `/-/versions`,
|
||
`/-/plugins`, `/-/settings`, `/-/actor`, `/-/databases`, schema endpoints,
|
||
token format & restriction semantics (`_r` abbreviations are wire format
|
||
now — they are stored inside issued tokens and cannot change silently).
|
||
- **Unstable/debug (documented as exempt):** `/-/threads`, `/-/actions`,
|
||
`/-/permissions`, `/-/allowed`, `/-/rules`, `/-/check`, `/-/messages`,
|
||
`/-/allow-debug`, `/-/patterns`, `/-/debug/autocomplete`, the `debug` and
|
||
`request` extras (the `debug` extra already self-describes as unstable),
|
||
`/-/api` and `/-/jump` (UI support endpoints), `/-/autocomplete` and
|
||
`/-/fragment` (UI support), `/-/foreign-key-suggestions` and
|
||
`/-/foreign-key-targets` (heuristic outputs).
|
||
- **Internal:** anything HTML-only (`/-/edit`, `/-/create-token`,
|
||
`/-/logout`, `/-/auth-token`).
|
||
|
||
Two details make tiering urgent rather than optional:
|
||
|
||
- **Extras are enumerable by clients** (`?_extra=extras` self-describes the
|
||
registry), so every public extra name is de-facto API. Mark each extra
|
||
stable or unstable in its class definition and surface that in the
|
||
`extras` output.
|
||
- **Pagination tokens leak implementation** (tilde-encoded pk values for
|
||
tables, plain integers for views). Declare them opaque now so the view
|
||
token can become keyset later without a "breaking" change.
|
||
|
||
---
|
||
|
||
## 10. Summary of P1 items (the pre-1.0 checklist)
|
||
|
||
1. ~~One canonical JSON error shape; retire the other three (§1).~~ ✅ Done.
|
||
2. ~~`Forbidden` → JSON 403 for JSON requests (§1a).~~ ✅ Done.
|
||
3. ~~No `ok: false` with HTTP 200 (§1b: `_shape=object`, write canned-query
|
||
SQL errors).~~ ✅ Done.
|
||
4. ~~Wrap `/-/plugins`, `/-/databases`, `/-/actions` top-level arrays in
|
||
objects (§2).~~ ✅ Done.
|
||
5. ~~Filter `/-/databases.json` by `view-database` or gate it behind
|
||
`permissions-debug` (§6).~~ ✅ Done.
|
||
6. ~~401 (not silent-anonymous) for invalid/expired bearer tokens (§1c).~~
|
||
✅ Done.
|
||
7. Publish explicit stability tiers, including extras and pagination-token
|
||
opacity (§9).
|
||
8. Resolve the looks-like-a-bug list (§8), especially trusted-query delete
|
||
and row-delete 500.
|
||
|
||
Everything in P2 is worth doing now because each item is breaking-to-fix
|
||
later; each P3 can be resolved by a sentence of documentation declaring the
|
||
current behavior intentional.
|