{
  "openapi": "3.1.0",
  "info": {
    "title": "Tabla API",
    "version": "1.0.0",
    "summary": "The one door into a Tabla database.",
    "description": "Everything the Tabla screen does runs through this API — the screen is just another client of it.\n\n**Field references.** Anywhere a field can be named — a filter, a sort, the `fields` projection, a cell key on write, a `merge_on` key — its stable `fld_…` id works as well as its name. Names read better; ids survive a rename. Anything automated should use ids, and ask for `key_by=id` on reads so the cells that come back are keyed by id too.\n\n**Batches** of records cap at 100 and are all-or-nothing: one bad row rejects the whole batch and nothing is written.\n\n**Pagination** is by opaque bookmark. Order is stable and ties break by record id, so pages never skip or repeat a record.",
    "contact": {
      "name": "Tabla",
      "url": "https://tabladb.com"
    },
    "license": {
      "name": "Proprietary — © Tabla. All rights reserved.",
      "url": "https://tabladb.com/terms"
    }
  },
  "servers": [
    {
      "url": "https://tabladb.com",
      "description": "Tabla"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Records",
      "description": "Reading and writing rows."
    },
    {
      "name": "Trash",
      "description": "Deleted records, and getting them back."
    },
    {
      "name": "Databases",
      "description": "Databases and tables."
    },
    {
      "name": "Fields",
      "description": "A table's schema, and its saved views."
    },
    {
      "name": "Webhooks",
      "description": "Deliveries out, and their log."
    },
    {
      "name": "Files",
      "description": "Attachments, import and export."
    },
    {
      "name": "Meta",
      "description": "Who you are, members, and health."
    }
  ],
  "paths": {
    "/api/databases": {
      "get": {
        "operationId": "listDatabases",
        "summary": "List every database you can open",
        "description": "Returns every database the calling identity is a MEMBER of — owned, or shared with you at any role — resolved from `catalog.database_members` alone (creating a database writes the creator's own `owner` member row, so membership is the complete answer). There is no pagination, no filter and no query parameter of any kind: the whole list comes back in one array, ordered by `position` then `created_at` ascending. Archived databases are NOT excluded — an archived one arrives with a non-null `archived_at` alongside the rest, and it is the caller's job to hide it. The row is handed out unfiltered (`SELECT *`), so it carries internal bookkeeping columns — `position`, `owner_id`, `created_by`, `updated_by` — that no other route accepts as input. Note the asymmetry with `createDatabase`: this lists memberships, while the creation cap counts only databases you OWN.",
        "responses": {
          "200": {
            "description": "The list, possibly empty.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "databases"
                  ],
                  "properties": {
                    "databases": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Database"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization` header, a malformed one, a key id that does not exist, a wrong secret, or a key belonging to a suspended account — all one indistinguishable `unauthorized`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. See the caps described on `createDatabase`.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createDatabase",
        "summary": "Create a database",
        "description": "Creates an empty database and, in the SAME transaction, the caller's own membership row at role `owner` — so the creator is immediately a member, not merely the recorded `owner_id`. `position` is assigned automatically as `COALESCE(MAX(position), -1) + 1` across ALL databases on the box — the sequence is global, not per user, and it is 0 only when the whole box holds none, and the new row starts with `color: null`, `icon: null`, `timezone: \"UTC\"`, `week_start: 1`, `weekend: [6, 7]`, `archived_at: null` — none of which this endpoint accepts as input. No table, field or view is created with it; a brand-new database is genuinely empty.\n\nA structural sanity cap applies: **100 databases** (`MAX_DATABASES_PER_USER` in lib/limits.ts), counted over the databases where you are the recorded `owner_id` — databases shared with you by someone else do not count against it. Exceeding it is refused as a 400, not a 402/403, and the message names the number and points at support. The cap is invisible: nothing in the API reports how close you are to it.\n\nThe name is stored EXACTLY as sent, including leading and trailing whitespace — the non-empty check trims before testing but the untrimmed string is what is written. There is no maximum length and no uniqueness rule: two databases may share a name.\n\nSuccess is 200, not 201, and no `Location` header is set.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Required. Must be a string that is non-empty after trimming; anything else (absent, null, a number, `\"   \"`) is refused with 400. Any language.",
                    "examples": [
                      "Casa Mayouie"
                    ]
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "database"
                  ],
                  "properties": {
                    "database": {
                      "$ref": "#/components/schemas/Database"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body was not valid JSON, was not a JSON object, `name` was missing/blank/not a string, or the 100-database cap has been reached.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. Three separate buckets guard the API (lib/http.ts): **600 requests per minute per verified key** (the published cap, charged only AFTER the secret is proven, so a leaked key id cannot be used to exhaust its owner's budget), a **3,000/min per-IP shield** for requests that carry any bearer, and **60/min per IP** for requests carrying none. The address is read from the LAST entry of `x-forwarded-for`.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}": {
      "patch": {
        "operationId": "updateDatabase",
        "summary": "Rename a database and/or set its accent colour",
        "description": "The REST door onto a database's settings accepts EXACTLY two keys — `name` and `color`. The other per-database settings that exist in the catalog (`icon`, `timezone`, `week_start`/`weekend`, and archive/unarchive) have working engine functions but **no REST route**: they are reachable only from the app's own screens, and sending them here does nothing and is not reported as an error. Unknown keys are silently ignored.\n\nAt least one of the two must be present or the request is refused. Presence is judged differently for each: `name` counts only if it is a string (an explicit `null` is read as absent), while `color` counts if the KEY is present at all, so `{\"color\": null}` is a valid request that clears the colour.\n\nUnlike creation, `name` here is not checked for emptiness — `{\"name\": \"\"}` is accepted and really does blank the database's name.\n\nWhen both keys are sent the two changes are applied as two separate statements, name first, so `updated_at` is bumped twice and the returned row is the one read back after the colour write (it does carry the new name). They are NOT in one transaction: a failure on the colour write leaves the rename applied.\n\nRequires role `editor` or higher.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The database id (`db_` + 12 characters). No format validation happens — any string that matches no row reads as 404.",
            "schema": {
              "type": "string",
              "examples": [
                "db_7k2mnpq4rstv"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "At least one of `name` / `color`.",
                "properties": {
                  "name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The new name. Must be a string if present and not null; an empty string IS allowed here. `null` is treated as if the key were absent (it does not clear the name)."
                  },
                  "color": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "One of the twelve brand pigments, or null for the neutral default. Validated against a list held in THIS route (the check is not in the storage function, so it guards this door only). Any other value — including a hex code, a colour name outside the twelve, or a non-string, which is stringified before the check — is refused with 400.",
                    "enum": [
                      "pomegranate",
                      "henna",
                      "saffron",
                      "ochre",
                      "olive",
                      "emerald",
                      "verdigris",
                      "nile",
                      "lapis",
                      "indigo",
                      "violet",
                      "rose",
                      null
                    ]
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The database as it is now.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "database"
                  ],
                  "properties": {
                    "database": {
                      "$ref": "#/components/schemas/Database"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "Body not valid JSON or not an object; neither `name` nor `color` supplied; `name` present but not a string; `color` present but neither null nor one of the twelve pigments.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member of this database but only a `viewer` — message: \"Viewers can't make changes.\" An admin reading through a support grant is refused here too, with a different message: \"Support access is read-only.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, OR one you are not a member of — the two are deliberately indistinguishable, so a wrong-tenant id never confirms that something is there.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteDatabase",
        "summary": "Delete a database and everything under it",
        "description": "Permanent and immediate — there is no trash for a database and no confirmation token. Inside one transaction every table it holds is torn down in turn: the real Postgres table in the `data` schema is DROPped, every connector table backing a link field on either side is dropped, and the catalog rows (tables, fields, views, webhooks, attachment records) cascade away with the database row. Attachment bytes in the object store are purged AFTER the commit on a best-effort basis — a storage hiccup there cannot fail the request or bring the database back, it only leaks bytes.\n\nRequires role `owner`; an `editor` is refused. Note this is stricter than renaming.\n\nThe response does not echo what was destroyed — no table count, no id list.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The database id.",
            "schema": {
              "type": "string",
              "examples": [
                "db_7k2mnpq4rstv"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted. `deleted` is always literally `true` — there is no false case; a failure is an error status instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResult"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member but not an `owner` — message: \"Only an owner can delete a database.\" An admin holding a support grant instead reads \"Support access is read-only.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or one you are not a member of.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/tables": {
      "get": {
        "operationId": "listTables",
        "summary": "List a database's tables, each with its fields",
        "description": "Returns every table in the database, ordered by `position` then `created_at`, and — unlike most list endpoints — each table arrives with its FULL field list already expanded, so one call is enough to learn a whole database's schema. The fields inside each table are the same objects `getTable` and the dedicated fields endpoint hand out, `operators` included; two ways to read a table's schema must never describe it differently. There is no pagination and no query parameter.\n\nReadable at role `viewer`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The database id.",
            "schema": {
              "type": "string",
              "examples": [
                "db_7k2mnpq4rstv"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The tables, possibly an empty list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "tables"
                  ],
                  "properties": {
                    "tables": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TableWithFields"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or one you are not a member of.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createTable",
        "summary": "Create a table",
        "description": "Creates a genuine Postgres table in the `data` schema and its catalog row together, in one transaction, so a failure on either leaves nothing behind. The real table is born with five internal columns only — `id`, `created_at`, `updated_at`, `created_by`, `deleted_at` — plus a partial index used by the trash; none of them are fields, so the new table has **no fields at all** and `fields` comes back as an empty array. `display_field_id` starts null, and `position` is `COALESCE(MAX(position), -1) + 1` within this database — so the database's first table is at 0.\n\nA structural sanity cap applies: **200 tables per database** (`MAX_TABLES_PER_DATABASE`), refused as a 400 naming the number. It is invisible — nothing reports how close you are.\n\nThe database's own `updated_at`/`updated_by` are bumped as a side effect (this happens on rename and delete too), so a database row's `updated_at` reflects structural work inside it, not just edits to the database itself.\n\nRequires role `editor`. Success is 200, not 201.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The database id the table is created in.",
            "schema": {
              "type": "string",
              "examples": [
                "db_7k2mnpq4rstv"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Required, must be non-empty after trimming, stored untrimmed. No length limit and no uniqueness rule — two tables in one database may share a name (unlike fields, which are unique within a table).",
                    "examples": [
                      "Bookings"
                    ]
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created. `fields` is always `[]` here.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "table"
                  ],
                  "properties": {
                    "table": {
                      "$ref": "#/components/schemas/TableWithFields"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "Body not valid JSON or not an object; `name` missing/blank/not a string; or the 200-table cap has been reached.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a `viewer` — \"Viewers can't make changes.\" An admin holding a support grant on this database is refused here too, with \"Support access is read-only.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or one you are not a member of.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}": {
      "get": {
        "operationId": "getTable",
        "summary": "Read one table with its fields",
        "description": "The table's catalog row plus its fields, ordered by `position` then `created_at` — the same field objects the dedicated fields endpoint returns, `operators` included. There is no way to ask for the row without the fields, and no query parameter of any kind.\n\nReadable at role `viewer`. Note the response gives you `database_id`, so a table id alone is enough to walk back up the tree.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_3n7pqrs2tuvw"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The table.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "table"
                  ],
                  "properties": {
                    "table": {
                      "$ref": "#/components/schemas/TableWithFields"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or one inside a database you are not a member of.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "renameTable",
        "summary": "Rename a table",
        "description": "The ONLY thing this endpoint changes is the name — `position` and `display_field_id` are settable elsewhere in the product but not through this route, and sending them here is silently ignored. `name` is required even though the verb is PATCH: a body with no `name` is a 400, never a no-op.\n\nThe returned table object does **not** carry a `fields` array — this is the one table-shaped response that omits it. Renaming also bumps the parent database's `updated_at`/`updated_by`.\n\nRequires role `editor`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table id.",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_3n7pqrs2tuvw"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Required, non-empty after trimming, stored untrimmed."
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The renamed table — WITHOUT a `fields` key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "table"
                  ],
                  "properties": {
                    "table": {
                      "$ref": "#/components/schemas/Table"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "Body not valid JSON or not an object; `name` missing, blank or not a string.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a `viewer` — \"Viewers can't make changes.\" An admin holding a support grant on this database is refused here too, with \"Support access is read-only.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or one inside a database you are not a member of.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteTable",
        "summary": "Drop a table, its real table and its connectors",
        "description": "Permanent and immediate — a table is not soft-deleted the way a record is, and no confirmation token is required. Everything happens in one transaction:\n\n· the real Postgres table in the `data` schema is DROPped (CASCADE);\n· every connector table backing a link field that touches this table **from either side** is dropped;\n· the paired link field living on the OTHER table — the mirror this table's links auto-created — is deleted too, so a delete here silently removes a field from a table you did not name;\n· any lookup or rollup field on that other table that rides one of those vanishing links is cascade-deleted with it, rather than being left permanently blank and unrepairable (lookup/rollup have no column of their own, so this is a catalog-only delete);\n· any webhook watching one of this table's fields has those ids stripped from its watch list;\n· this table's own fields, views, webhooks and attachment rows cascade away with the row.\n\nAttachment bytes are purged from the object store after the commit, best-effort. The parent database's `updated_at`/`updated_by` are bumped.\n\nRequires role `owner` — an `editor` who may create and rename tables may NOT delete one. The response names only the table you asked for; the collateral fields deleted on other tables are not listed.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table id.",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_3n7pqrs2tuvw"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted. `deleted` is always literally `true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResult"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member but not an `owner` — \"Only an owner can delete a table.\" An admin holding a support grant instead reads \"Support access is read-only.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or one inside a database you are not a member of.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/fields": {
      "get": {
        "operationId": "listFields",
        "summary": "List a table's fields",
        "description": "Returns every field row of the table, ordered by `position` then `created_at` — the same order the grid draws columns in. Nothing is filtered out: hidden-in-a-view fields, link mirrors, buttons and computed fields all appear, because hiding is a saved-view concern (see the view `config.hidden_fields`) and not a property of the field.\n\nEach entry is the raw `catalog.fields` row plus `operators` — derived at response time from the same operator table the query builder itself reads, never a second hand-maintained copy. Two cases return an EMPTY list rather than their storage kind's operators: a `button` (it holds no value), and a lookup/rollup that is not filterable — every rollup is, and a lookup is only when it surfaces a plain scalar, so a lookup surfacing a select, link or attachment field advertises nothing.\n\nAccess: any member of the field's database passes, whatever their role — there is no role high enough to be refused here, so 403 is not reachable on this operation. A table id belonging to another account reads as 404, exactly like one that does not exist.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_5m8qrtu3vwxy"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The table's fields.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "fields"
                  ],
                  "properties": {
                    "fields": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Field"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, or it belongs to an account this credential is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createField",
        "summary": "Create a field",
        "description": "Adds a field to the table. Most costumes become a real column on the table's Postgres table in the same transaction as the catalog row, so a refusal leaves nothing half-made (law 7).\n\nThings that are true here and nowhere documented by convention:\n\n• **`name` is trimmed before it is stored** (`\" Rate \"` is saved as `\"Rate\"`), and must be unique within the table — a collision is 409, not 400.\n• **Creating a `link` creates TWO field rows**: the forward field on this table and its mirror on the target table (`options.reverse_field_name`, defaulting to *this table's* name). The response returns ONLY the forward field — the mirror's id is in the forward field's `options.pair_field_id`; fetch it with a second list call on the target table.\n• The **first field that can name a record** becomes the table's display field automatically. Link, attachment, checkbox, button and lookup/rollup costumes cannot, so a table whose first field is one of those stays without a display field.\n• A **formula/lookup/rollup** field may be created either with its concrete costume (`formula_number`, `lookup_date`, `rollup_number`, …) or with the one-word door `\"formula\"` / `\"lookup\"` / `\"rollup\"`, in which case the concrete costume is inferred server-side — from the expression's own result type, from the storage kind of the surfaced field, or from the aggregation. Those three door words are accepted here but are NOT in the list the \"Unknown field type\" error prints, and they are NOT accepted by PATCH.\n• A formula's expression is compiled to a Postgres `GENERATED ALWAYS AS (…) STORED` column, so Postgres itself proves it before the field exists. What is stored back in `options.expression` is the *canonical* (fully braced) form of what you sent, plus `options.referenced_field_ids`.\n• `options.unique` is honoured only for `single_line_text`, `email`, `phone`, `url`, `number`, `currency`, `percent`, `date`, `single_select` — those get a real partial UNIQUE index (`WHERE deleted_at IS NULL`). Sent on any other costume it is stored in the pouch and enforces nothing.\n• A table is capped at 500 fields; the 501st is refused with 400. A link is checked TWICE against that cap — once on this table, once on the target, since the mirror consumes a slot there too.\n\nRequires the **editor** role on the table (a viewer gets 403 \"Viewers can't make changes.\"). Creating a link additionally requires the **editor** role on the target table, and the target must be in the same database. Creating a lookup or rollup requires at least the **viewer** role on the table at the far end of the chosen link — a link left behind after the author lost access there does not grant a fresh read through a new lookup.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_5m8qrtu3vwxy"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FieldCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The field that was created. For a link this is the forward side only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "field"
                  ],
                  "properties": {
                    "field": {
                      "$ref": "#/components/schemas/Field"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The request body, the type, or the settings pouch were refused. Reachable causes include: `name`/`type` missing or not a non-empty string; an unknown `type` (the message lists every valid one); `options` not a JSON object; an unknown key in `options`; a select choice without a name, a duplicate new choice name, more than 1000 choices or a choice name over 500 characters, or the reserved name `__tabla_no_such_choice__`; a link without `options.target_table_id`, or one pointing at a table in a different database; a lookup/rollup without `options.link_field_id`, without `options.target_field_id`, naming a link field that is not on this table, surfacing a field that is itself a lookup/rollup, or an aggregation that does not fit the field it aggregates; a `conditions` filter that does not compile against the target table; more than four picker fields (or more than one attachment among them, or the target's display field); a formula the compiler or Postgres refuses; a button whose resolved action is `link` but carries no `url_template`, one over 2000 characters, or one that is not http/https, a button whose resolved action is `webhook` but carries no `webhook_id`, or a button `condition` holding an invalid relative-date value; and the 500-fields-per-table cap — charged against the target table too when a link's mirror lands there.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the database but their role is too low — a viewer attempting a write. Field writes read \"Viewers can't make changes.\"; view writes read \"Viewers can't change views.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The table, a link's target table, or a button's target webhook is not visible to this account (it does not exist, or it belongs to another account).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "A field with that name already exists in this table — or, for a link, the mirror's name is already taken on the target table (pass `options.reverse_field_name`), or a self-link was given the same name for both sides.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/fields/{id}": {
      "patch": {
        "operationId": "updateField",
        "summary": "Rename a field, change its type, or replace its settings",
        "description": "At least one of `name`, `type`, `options` must be present, or the call is refused with 400.\n\n**The response's `field` is the stored catalog row and does NOT carry `operators`** — unlike the list and create responses. Re-list the table's fields if you need the operator vocabulary after an edit.\n\n**`name` is not checked for emptiness here** (only for uniqueness within the table), so `{\"name\": \"\"}` really does rename the field to an empty string. It is also not trimmed, unlike on create. Renaming a field usually rewrites any sibling formula's stored `{Field Name}` text — except when the new name contains a brace or has leading/trailing spaces, in which case the expression text is deliberately left alone rather than written in a form that would no longer parse. Three edit paths skip that rewrite entirely: a rename sent in the SAME call as `options` on a formula field, on a lookup/rollup, or on a link field. Rename those on their own (`{\"name\": …}` with no `options`) if a formula references them.\n\n**`options` replaces the pouch wholesale** — `{\"options\": {}}` or `{\"options\": null}` empties it. One deliberate exception: on a `single_select`/`multi_select`, a payload that OMITS `choices` keeps the stored choices (an explicit `\"choices\": []` still clears them). Per costume, an `options` write behaves differently:\n• **formula** — the payload MUST contain `expression`; anything else is refused with 400. The expression is recompiled and the column rebuilt, so the concrete costume can change (`formula_number` → `formula_text`); that is blocked if a lookup/rollup depends on the current type. Other keys in the same payload are merged as presentation.\n• **lookup / rollup** — fully re-resolved from the payload (through-link, surfaced field, aggregation, target conditions), so it must be a complete config, not a patch.\n• **button** — fully re-validated and replaced. `label` is silently truncated to 40 characters and defaults to `\"Button\"`; `action` is inferred from `webhook_id` when absent.\n• **link** — `target_table_id`, `direction`, `pair_field_id` and `allow_multiple` are locked after creation; sending a *different* value for any of them is refused by name. Only `conditions` (the picker constraint) and `picker_field_ids` are editable, and they are merged into the existing wiring.\n\n**Frozen after creation, enforced server-side:** a date field's `include_time` (it fixed the real column type), and `unique` — an options edit can neither add nor remove it; the stored flag is carried forward for eligible costumes and dropped for the rest.\n\n**Which `type` changes are allowed** (M11 — a real `ALTER COLUMN … TYPE … USING`, so Postgres proves every value fits or the whole change rolls back):\n• Free, no data touched: any change within one storage kind (`single_line_text` ↔ `email` ↔ `phone` ↔ `url` ↔ `member`; `number` ↔ `currency` ↔ `percent` ↔ `rating` ↔ `duration`; `multi_select` ↔ `multi_member`), plus `long_text` paired with ANY text-storage costume — `text` and `long_text` are treated as one family, so `long_text` ↔ `email` and `long_text` ↔ `member` are free as well, not just `long_text` ↔ `single_line_text`.\n• Converted: text/long_text → number, number → text/long_text, checkbox → text/long_text, and `single_select` → `multi_select`.\n• Refused by name: `multi_select` → `single_select` (a cell may already hold several choices); any change into or out of link, attachment, formula, lookup, rollup or button; and every other cross-storage pair.\n• A conversion is also refused if a formula on this table, or a lookup/rollup on this or any table linking to it, depends on the field — the offending field is named. If a value fails the cast, the reply says so rather than leaking the Postgres error.\n• The one-word doors `\"formula\"`/`\"lookup\"`/`\"rollup\"` are NOT valid here; use the concrete costume (and note that changing *into* one is refused anyway).\n\nRequires the **editor** role.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The field's id (`fld_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "fld_3n7pqrs2tuvw"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FieldUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored field row after the edit — the same shape as `Field` but WITHOUT `operators`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "field"
                  ],
                  "properties": {
                    "field": {
                      "$ref": "#/components/schemas/StoredField"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "Nothing to change (none of `name`/`type`/`options` present); `name` or `type` present but not a string; an unknown `type`; a refused or impossible type conversion (named); a dependent formula or lookup/rollup blocking the conversion (named); an unknown key in `options` — but ONLY on the paths that re-normalise the pouch (a plain field, a select, or any `type` change): a formula, lookup/rollup, button or link `options` edit never runs that check, so an unrecognised key there is not refused (it is silently kept on a formula and silently dropped on the other three); a select-choice violation; an attempt to change `include_time` or the link wiring; a formula `options` payload with no `expression`, or one that will not compile; an incomplete lookup/rollup config; or an invalid button configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the database but their role is too low — a viewer attempting a write. Field writes read \"Viewers can't make changes.\"; view writes read \"Viewers can't change views.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such field for this account (absent, or another account's). A button's newly-named target webhook that is not in this database also reads as 404.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Another field in this table already has that name.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteField",
        "summary": "Delete a field",
        "description": "Drops the field's real column (or, for a link, the whole shared connector table) and its catalog row, in one transaction. There is no trash for a field and no confirmation token — a deleted field's data is gone.\n\nConsequences the caller should expect:\n• **Deleting one side of a link deletes both sides.** The mirror on the other table disappears with it, and the response names only the id you asked for.\n• Deleting an **attachment** field purges the stored files' bytes as well (best-effort, after the transaction commits).\n• If this was the table's **display field**, the table is simply left without one.\n• The id is stripped from any webhook's `watched_field_ids`; when that empties the list, a `record_updated` webhook is switched OFF rather than silently widened to every field.\n\nFive dependency guards refuse the delete with 400, each naming the field that must go first: a formula referencing this field; a lookup/rollup riding this link; a lookup/rollup riding this link's *mirror*; a lookup/rollup surfacing or filtering by the mirror; and a lookup/rollup on this or any linking table surfacing or filtering by this field.\n\nRequires the **editor** role.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The field's id (`fld_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "fld_3n7pqrs2tuvw"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The field is gone.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "deleted"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "The field id that was deleted. A link's mirror is deleted too but is not listed here."
                    },
                    "deleted": {
                      "type": "boolean",
                      "const": true
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "A formula, lookup or rollup depends on this field (or on its link mirror). The message names the dependent field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the database but their role is too low — a viewer attempting a write. Field writes read \"Viewers can't make changes.\"; view writes read \"Viewers can't change views.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, or it belongs to an account this credential is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/views": {
      "get": {
        "operationId": "listViews",
        "summary": "List a table's saved views",
        "description": "Returns the table's views ordered by `position` then `created_at`. A view is saved *view-bar state* — filter, sort, hidden fields, row height, colour rules, and the per-view-type settings — never data, and never a permission boundary: everything a view hides is still readable through the records endpoints.\n\nAny member of the database passes, whatever their role, so 403 is not reachable here.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_5m8qrtu3vwxy"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The table's views.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "views"
                  ],
                  "properties": {
                    "views": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/View"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, or it belongs to an account this credential is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createView",
        "summary": "Create a saved view",
        "description": "Creates a view at the end of the table's list (`position` = the current maximum + 1, starting at 0).\n\n**View names are not checked for uniqueness** — two views may share a name, and no 409 is possible here. `name` is required and must be non-empty, but it is stored exactly as sent, untrimmed.\n\n**`config` is not validated at all on this route** — no key is checked, no shape is imposed, and unlike the PATCH route this one does not even validate relative-date values nested inside it, so the same payload PATCH would refuse is accepted here. Absent or `null` becomes `{}`. It is not type-checked either: a non-object `config` is handed to a jsonb column with no guard of its own. See the `ViewConfig` schema for the keys the product itself writes.\n\nRequires the **editor** role (a viewer gets 403 \"Viewers can't change views.\").",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_5m8qrtu3vwxy"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "The view's label. Required, must be a non-empty string (whitespace-only is refused). Not unique-checked; not trimmed."
                  },
                  "config": {
                    "$ref": "#/components/schemas/ViewConfig",
                    "description": "The saved state. Absent or null → `{}`. Not validated on this route."
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The view that was created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "view"
                  ],
                  "properties": {
                    "view": {
                      "$ref": "#/components/schemas/View"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "`name` missing, not a string, or empty/whitespace-only.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the database but their role is too low — a viewer attempting a write. Field writes read \"Viewers can't make changes.\"; view writes read \"Viewers can't change views.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, or it belongs to an account this credential is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/views/{id}": {
      "patch": {
        "operationId": "updateView",
        "summary": "Rename a view and/or replace its config",
        "description": "At least one of `name`, `config` must be present, or the call is refused with 400. `\"name\": null` counts as absent — but `\"config\": null` does NOT: presence is tested by the key, and the null is passed straight through to a column declared NOT NULL, so it is not a way to clear a config. Send `{}` for that.\n\n**`config` REPLACES the whole stored config** — this route never merges. To change one key, read the view first and send the merged object back.\n\nUnlike create, this route walks the submitted config and validates every **relative-date value** it finds (`{relative: \"today\"|\"now\", offset?: integer with |offset| ≤ 366, unit?: \"day\"|\"week\"|\"month\"}`) — an unknown anchor, unit or extra key is refused with 400. The walk stops after eight levels of nesting, so a relative value buried deeper than that is stored unchecked. Nothing else in the config is validated: unknown keys, a filter naming a deleted field, or a sort on a field that no longer exists are all accepted on purpose, because the display surfaces tolerate stale state rather than break a whole view.\n\n**`name` is not checked for emptiness** here, so `{\"name\": \"\"}` really does blank the view's label. Names are still not unique-checked.\n\nWhen both `name` and `config` are sent, the rename and the config write are two separate statements (not one transaction) and the returned view is the one read back after the config write.\n\nRequires the **editor** role.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The view's id (`viw_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "viw_2p6rstu4vwxy"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "New label. `null` is treated as \"not sent\". An empty string is accepted and stored."
                  },
                  "config": {
                    "$ref": "#/components/schemas/ViewConfig",
                    "description": "Replaces the stored config wholesale. Sending `null` is not \"absent\" and is not a way to clear it — send `{}`."
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The view after the edit.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "view"
                  ],
                  "properties": {
                    "view": {
                      "$ref": "#/components/schemas/View"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "Neither `name` nor `config` present; `name` present but not a string; or a relative-date value inside `config` (within eight levels of nesting) names an unknown moment or unit, carries a non-integer or out-of-range offset (|offset| > 366), or carries a key other than `relative`/`offset`/`unit`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the database but their role is too low — a viewer attempting a write. Field writes read \"Viewers can't make changes.\"; view writes read \"Viewers can't change views.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, or it belongs to an account this credential is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteView",
        "summary": "Delete a view",
        "description": "Deletes the saved view. No records are touched — a view holds no data.\n\nA table must always keep at least one view: deleting the last one is refused with 400 (\"Can't delete the only view.\"). There is no typed-confirmation ritual here, unlike a table or field delete, because a view is a saved preference rather than user data.\n\nRequires the **editor** role.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The view's id (`viw_` + 12 characters).",
            "schema": {
              "type": "string",
              "examples": [
                "viw_2p6rstu4vwxy"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The view is gone.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "deleted"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "The view id that was deleted."
                    },
                    "deleted": {
                      "type": "boolean",
                      "const": true
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "This is the table's only view.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer credential, or one that does not resolve to a live API key. Message: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" The gate resolves the bearer API key ONLY — a browser session cookie alone is refused here — and a suspended account's keys stop resolving, so a suspension reads as 401 rather than 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the database but their role is too low — a viewer attempting a write. Field writes read \"Viewers can't make changes.\"; view writes read \"Viewers can't change views.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, or it belongs to an account this credential is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and — before any credential is checked — 3000/minute per caller address with a bearer header present or 60/minute without one. Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Whole seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled error. The body is always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever returned to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/records": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The table's id (`tbl_` + 12 characters). A table id belonging to another account reads exactly like a nonexistent one: 404, never 403.",
          "schema": {
            "type": "string",
            "examples": [
              "tbl_4m8qrst3uvwx"
            ]
          }
        }
      ],
      "get": {
        "operationId": "listRecords",
        "summary": "List records in a table",
        "description": "Keyset-paged listing with the §9 filter language, sort, search and a field projection.\n\nThings that are not guessable:\n\n• **There is no default sort.** With no `sort`, rows come back ordered by the record id alone (`ORDER BY r.id ASC`), and record ids are RANDOM 12-character strings — so the default order is stable between requests but is neither creation order nor anything a human would recognise. Ask for a `sort` if order matters.\n• **Trashed records are never listed.** Soft-deleted rows (`deleted_at` set) are excluded at the SQL level; they are only reachable through the trash routes.\n• **Filters compile STRICTLY here.** A condition naming a renamed/deleted field, or an operator the field's storage kind does not take, is a 400 that names it — the API never quietly returns nothing. (The app's own screens compile leniently; that leniency is not part of this contract, and there is no parameter to ask for it.)\n• **`search` can return zero rows for a structural reason**: it only matches `text`/`long_text` storage columns (excluding lookup/rollup and button costumes). If the table has no such field at all, the search compiles to `false` and the page is empty rather than unfiltered. Member fields ARE searched, because a member cell is text storage holding an email.\n• Paging is keyset, forward-only. `bookmark` is present in the response ONLY when at least one more row exists (the lister reads `page_size + 1` rows to find out).\n• Every response carries a companion `field_ids` map (name → id), or `field_names` (id → name) when `key_by=id`, built from exactly the fields the payload contains.",
        "parameters": [
          {
            "name": "filter",
            "in": "query",
            "required": false,
            "description": "The §9 filter, as URL-encoded JSON. An empty string means no filter. Not valid JSON → 400. Valid JSON that is not an object (a list, a number, a string) → 400. `null` → treated as no filter.",
            "schema": {
              "type": "string"
            },
            "example": "{\"join\":\"and\",\"conditions\":[{\"field\":\"Status\",\"op\":\"is\",\"value\":\"Paid\"}]}"
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "A comma-separated list of sort terms, applied in order, with the record id always appended as the final tiebreak. Each term is a field id or field name, optionally prefixed `-` or suffixed `:asc` / `:desc` (case-insensitive) for direction.\n\nThe `-` prefix is checked FIRST, so a combined form like `-Name:desc` does NOT parse as a direction — the remainder `Name:desc` is then looked up as a field reference and fails with 400. Use one form or the other.\n\nOnly these storage kinds can be sorted on: text, long_text, number, date, checkbox, single_select. A `multi_select` (so also `multi_member`), `link`, `attachment`, `button`, or any lookup/rollup costume is refused with 400 — a list is not an order, and the rest have no column to order by. A single-select sorts by its choices' defined order, not alphabetically. Ascending puts NULLs last; descending puts them first.",
            "schema": {
              "type": "string"
            },
            "examples": {
              "descending": {
                "value": "-Created"
              },
              "twoTerms": {
                "value": "Status,Nightly rate:desc"
              }
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match (ILIKE) across every text/long_text storage column of the table, ORed together. A literal `%` or `_` in the term is escaped, not treated as a wildcard. A blank or whitespace-only term is ignored entirely.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "fields",
            "in": "query",
            "required": false,
            "description": "A comma-separated list of field ids or field names, narrowing which fields each record's `fields` object carries (and therefore what the `field_ids`/`field_names` map describes). Order follows the list as given; a field named twice is kept once. An unknown reference is 400; an ambiguous NAME is 400 telling you to use the id. Empty or whitespace-only means every field.\n\nThis only narrows the payload — a filter, sort or search may still reference a field left out of the projection.",
            "schema": {
              "type": "string"
            },
            "example": "Name,fld_3n7pqrs2tuvw"
          },
          {
            "name": "page_size",
            "in": "query",
            "required": false,
            "description": "How many records this page carries. Must be a whole number between 1 and 1000; anything else (a decimal, 0, 1001, a non-numeric string) is 400. An empty string means the default.",
            "schema": {
              "type": "integer",
              "default": 100,
              "minimum": 1,
              "maximum": 1000
            }
          },
          {
            "name": "bookmark",
            "in": "query",
            "required": false,
            "description": "The previous response's `bookmark`, passed back verbatim to continue. It is a base64url-encoded cursor holding the last row's sort values plus its id — opaque, do not build one. A string that isn't ours is 400 (\"Invalid bookmark.\"). A bookmark whose number of sort values doesn't match the `sort` sent with THIS request is 400 (\"Bookmark does not match the sort.\"), so a page must be continued with the same sort it was started with.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cell_format",
            "in": "query",
            "required": false,
            "description": "How select cells read: `names` (the choice's current name) or `ids` (the stored 8-character choice id, which survives a rename). Empty string = `names`. Any other value is 400. It does NOT affect member/multi_member cells (always emails), link cells, or attachment cells.",
            "schema": {
              "type": "string",
              "enum": [
                "names",
                "ids"
              ],
              "default": "names"
            }
          },
          {
            "name": "key_by",
            "in": "query",
            "required": false,
            "description": "How each record's cells are keyed: `name` (human-readable) or `id` (`fld_…`, survives a field rename — what anything automated should ask for). Empty string = `name`. Any other value is 400. Choosing `id` also switches the companion map from `field_ids` to `field_names`.",
            "schema": {
              "type": "string",
              "enum": [
                "name",
                "id"
              ],
              "default": "name"
            }
          },
          {
            "name": "link_display_cap",
            "in": "query",
            "required": false,
            "description": "Return `display` text for at most this many linked records per link cell; every linked id is still returned, only the names past the cap are omitted (the key is ABSENT, not null — see LinkedRecord).\n\nSILENTLY IGNORED when it is not a whole number in 1…1000 — `0`, `-5`, `2000`, `abc` all simply mean \"no cap\" rather than raising an error. Omitting it (the normal case for an API client) also means no cap: every linked record carries its display text.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of records.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecordPage"
                }
              }
            }
          },
          "400": {
            "description": "A malformed parameter: unparseable `filter`; a filter whose `conditions` is present but not a list, whose `join` is neither \"and\" nor \"or\", or whose condition is not an object or carries no string `op`; an unknown/ambiguous field reference in `filter`/`sort`/`fields`, an operator the field's storage kind doesn't accept, a filter value of the wrong type, an unsortable field, a `page_size` outside 1…1000, a bad `cell_format`/`key_by`, or a bookmark that is invalid or doesn't match the sort.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a key whose owning account is suspended.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table — genuinely absent, OR belonging to a database this account is not a member of. The two are deliberately indistinguishable. Note reads never produce 403: any membership at all is enough to read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. Carries a `Retry-After` header in whole seconds. Two buckets apply: 600 requests/minute per verified API key, and an address-keyed shield before the key is even checked (3000/min with a bearer present, 60/min without).",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createRecords",
        "summary": "Create records",
        "description": "Creates up to 100 records in one all-or-nothing transaction — if any record in the batch is rejected, nothing is written.\n\nWhat the code actually enforces:\n\n• A record body is `{ \"fields\": { … } }`. `fields` may be omitted or null, which creates an empty record. Each key is a field id or field name; naming the same field twice in one record (e.g. by id and by name) is 400.\n• **Attachments cannot be set at create time, ever.** An attachment id must have been uploaded into the exact (record, field) it is written to, and the record does not exist yet — so any `att_…` id here is refused with 400: \"Unknown attachment id(s)\" if the id is not this table's, otherwise \"Attach files after the record is saved\". An empty list is fine.\n• Writing to a formula, lookup, rollup, or button field is refused with 400 naming the field.\n• A link field takes a list of `rec_…` ids OR the `{id, display}` objects a read returns (so a GET body can be POSTed back). Every id must exist and not be in the trash; a missing one is 400 naming the ids. A FORWARD link field that does not allow multiple refuses a list longer than 1; the REVERSE mirror of such a link is deliberately not capped (one record on the single side may be pointed at by many on the other).\n• A `duration` cell accepts `\"1:30\"` or a number of seconds and stores seconds; a negative duration is 400. A `rating` is rounded, clamped to the field's `max`, and anything ≤ 0 becomes null.\n• A field marked unique returns 400 (not 409) quoting the clashing value.\n• Records created here are stamped with the calling account as their creator — that is what later lets an editor delete only their own rows.\n• Webhooks: `record.created` deliveries are enqueued in the SAME transaction as the write. A batch of 50 or more records deliberately skips per-record webhooks altogether.\n\nThe response's cells are ALWAYS keyed by field name — `key_by` is a read-only parameter and there is no field map on write responses.",
        "parameters": [
          {
            "name": "cell_format",
            "in": "query",
            "required": false,
            "description": "How select cells read back in the response (`names` or `ids`). It does not change how values are ACCEPTED — a select write resolves a choice id or a choice name either way. Any other value is 400.",
            "schema": {
              "type": "string",
              "enum": [
                "names",
                "ids"
              ],
              "default": "names"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "records"
                ],
                "properties": {
                  "records": {
                    "type": "array",
                    "description": "1 to 100 record bodies. Not a list → 400; empty → 400; over 100 → 400 stating the count received.",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "$ref": "#/components/schemas/RecordWrite"
                    }
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The created records, read back inside the same transaction, in the order sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecordBatch"
                }
              }
            }
          },
          "400": {
            "description": "Body isn't a JSON object; `records` missing/not a list/empty/over 100; a record isn't an object; `fields` isn't an object; an unknown or ambiguous field reference; the same field twice in one record; a value of the wrong type for its field; a select value that is neither one of the field's choice ids nor one of its choice names; a duration that can't be parsed or is negative; an attachment value that isn't a list of `att_…` ids; a write to a formula/lookup/rollup/button; an attachment id at create time; a link id that doesn't exist; too many links for a single-record link; or a unique-field clash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A member whose role is `viewer`: \"Viewers can't change records.\" Also returned to an admin holding read-only support access.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or a table outside this account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateRecords",
        "summary": "Update records",
        "description": "Updates up to 100 existing records in one all-or-nothing transaction.\n\nOnly the fields present in `fields` are touched — a field left out is not cleared. To clear a cell send `null` (or `[]` for a link/attachment cell).\n\nOptimistic concurrency (`if_updated_at`), per record and opt-in:\n• Send back the `updated_at` string you last read for that record. Omit it and the write is plain last-write-wins.\n• Every token is compared BEFORE anything is written. If ANY record's token doesn't match, the whole batch is refused with 409 and nothing is written — the 409's `error.records` carries each stale record exactly as it is now (name-keyed cells, select names), so a client can heal in place without a second request.\n• The token moves for reasons other than a direct edit: writing a link on record A also bumps `updated_at` on the counterpart records at the far end, and on any record a single-link \"steal\" took a link away from. A pure reorder of the same link ids bumps nothing.\n\nOther behaviour worth knowing:\n• Listing the same record id twice in one batch is 400.\n• Updating a record that is in the trash is **400** (\"is in the trash. Nothing was changed.\"), not 404 — 404 is reserved for an id that doesn't exist at all.\n• Attachments: an `att_…` id may only be written into the exact record and field it was uploaded into; anything else is 400. Any attachment id dropped out of a cell by an update that sends a LIST for that field (`[]` included) has its bytes and catalog row permanently purged after the transaction commits — there is no detach-but-keep. Sending `null` for an attachment field is the one exception: it empties the cell without purging, leaving those files in the locker unreferenced.\n• Webhooks: `record.updated` is enqueued in the same transaction, carrying the before-image and the list of fields this call touched. 50 or more records in one batch skips per-record webhooks.\n\nResponse cells are always keyed by field name.",
        "parameters": [
          {
            "name": "cell_format",
            "in": "query",
            "required": false,
            "description": "How select cells read back (`names` or `ids`). Any other value is 400.",
            "schema": {
              "type": "string",
              "enum": [
                "names",
                "ids"
              ],
              "default": "names"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "records"
                ],
                "properties": {
                  "records": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "$ref": "#/components/schemas/RecordUpdate"
                    }
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated records, read back inside the same transaction, in the order sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecordBatch"
                }
              }
            }
          },
          "400": {
            "description": "Everything the create path can refuse, plus: a record missing its `rec_…` id; `if_updated_at` that isn't a string; the same id twice in one batch; or a target record that is in the trash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A `viewer` member (or read-only support access): \"Viewers can't change records.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, a table outside this account, or a record id that does not exist in this table (\"Nothing was changed.\").",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "At least one `if_updated_at` token was stale. Nothing was written. `error.records` holds ONLY the stale records, as they are now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "upsertRecords",
        "summary": "Upsert records by a merge key",
        "description": "Matches each incoming record against existing rows on the `merge_on` fields: a match is updated, no match is created. Up to 100 records, one transaction, all-or-nothing — an ambiguity discovered halfway through leaves nothing behind.\n\nMatching rules, exactly as implemented:\n• `merge_on` is a non-empty list of field ids or names. A field can only be a merge key if its STORAGE KIND is one of text, long_text, number, date, checkbox, single_select — a set is not an identity, so multi_select and link are refused; attachment lives in another table; formula, lookup, rollup and button have no column of their own. Naming a field twice in `merge_on` is 400.\n• With several merge fields the key is the whole tuple — all of them must match.\n• Matching happens on the ENCODED value, inside the transaction. So a single-select key matches on the stored choice id, and a select choice supplied by name resolves before matching.\n• **Every record in the batch must carry every merge field.** A missing one is 400 — it is never defaulted to NULL, because that would silently match every blank-keyed row.\n• **Two records in the same batch sharing one key is 400**, naming the collision, before anything is written.\n• **A key matching more than one EXISTING record is 400**, naming the count and the key: the key cannot identify a record, and nothing is guessed.\n\nA created row fires `record.created`; an updated row fires `record.updated` — an upserted row never reports the wrong event. A batch of 50 or more skips per-record webhooks.\n\nUnlike PATCH, there is no `if_updated_at` here — but a 409 is still possible: if a matched record is deleted by someone else between the match and the write, the batch aborts with a conflict whose `records` array is EMPTY.",
        "parameters": [
          {
            "name": "cell_format",
            "in": "query",
            "required": false,
            "description": "How select cells read back (`names` or `ids`). Any other value is 400.",
            "schema": {
              "type": "string",
              "enum": [
                "names",
                "ids"
              ],
              "default": "names"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "records",
                  "merge_on"
                ],
                "properties": {
                  "merge_on": {
                    "type": "array",
                    "description": "The fields (ids or names) that identify a record. Absent, not a list, or empty → 400.",
                    "minItems": 1,
                    "items": {
                      "type": "string"
                    },
                    "examples": [
                      [
                        "Email"
                      ],
                      [
                        "Guest",
                        "Check-in"
                      ]
                    ]
                  },
                  "records": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "$ref": "#/components/schemas/RecordWrite"
                    }
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Every record written, in the order sent, plus which ids were created and which were updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UpsertResult"
                }
              }
            }
          },
          "400": {
            "description": "Everything the create path can refuse, plus: `merge_on` absent/not a list/empty; a merge field that can't identify a record; a merge field named twice; a record missing one of the merge fields; two records in the batch sharing a key; or a key that already matches more than one existing record.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A `viewer` member (or read-only support access): \"Viewers can't change records.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or a table outside this account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "A matched record was removed while the batch was being written. Nothing was written. `error.records` is an empty array here — there is no stale body to hand back.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteRecords",
        "summary": "Move records to the trash",
        "description": "**This is a soft delete.** The rows stay in their real table with `deleted_at` set, their attachments stay in the locker, and they can be restored from the trash — permanent removal is a separate purge operation. `record.deleted` webhooks fire exactly once, here, carrying the last known body; a later purge fires nothing.\n\nIds may be supplied two ways, and the query string WINS: if `?records=` is present and non-empty the request body is never read. Ids are de-duplicated; up to 100 distinct ids (the cap is checked against the list as sent, before de-duplication).\n\nRole rule (M16): an `owner` may trash anything; an `editor` may trash only records they themselves created. A record created before memberships existed has no creator and can only be trashed by an owner. A batch containing even one record the caller doesn't own is refused with 403 naming those ids, and nothing is deleted.\n\nIf an id is already in the trash the batch fails with 400 naming it; if an id doesn't exist at all it fails with 404. Either way nothing is deleted.",
        "parameters": [
          {
            "name": "records",
            "in": "query",
            "required": false,
            "description": "A comma-separated list of `rec_…` ids, for clients that cannot send a body on DELETE. Entries are trimmed and blanks dropped. When this is present and non-empty the body is ignored entirely.",
            "schema": {
              "type": "string"
            },
            "example": "rec_7k2mnpq4rstv,rec_9x3ytuvw5abc"
          }
        ],
        "requestBody": {
          "required": false,
          "description": "Read only when `?records=` was not supplied. A body that is absent or not valid JSON produces a 400 explaining both ways to pass ids.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "records"
                ],
                "properties": {
                  "records": {
                    "type": "array",
                    "description": "1 to 100 `rec_…` ids. Not a list → 400; empty → 400; over 100 → 400; any entry that isn't a `rec_`-prefixed string → 400.",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "type": "string"
                    }
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One entry per trashed record, in the order the engine reported them.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "records"
                  ],
                  "properties": {
                    "records": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "deleted"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "examples": [
                              "rec_7k2mnpq4rstv"
                            ]
                          },
                          "deleted": {
                            "type": "boolean",
                            "const": true,
                            "description": "Always true. It means \"moved to the trash\", not \"erased\"."
                          }
                        },
                        "additionalProperties": false
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "No ids supplied either way; `records` not a list, empty, or over 100; an entry that isn't a `rec_…` id; or one of the ids is already in the trash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A `viewer` (\"Viewers can't change records.\") or an admin holding read-only support access (\"Support access is read-only.\"), or an `editor` naming records they did not create — the message lists exactly which ids. Nothing was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, a table outside this account, or one of the record ids does not exist (\"Nothing was deleted.\").",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/records/list": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The table's id (`tbl_` + 12 characters).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "listRecordsByPost",
        "summary": "List records with the query in a body",
        "description": "The identical lister as `GET /tables/{id}/records`, for filters too long to survive a URL. Same defaults, same caps, same strict filter compilation, same response shape.\n\nTwo real differences from the GET:\n• `filter` may be the filter OBJECT itself (a JSON string is still accepted and parsed).\n• **`link_display_cap` is not read here at all** — sending it has no effect; link cells always carry every linked record's display text on this route.\n\n`cell_format` and `key_by` are read from the BODY here, not the query string; sending them as query parameters does nothing. Each of `sort`, `search`, `fields`, `bookmark`, `cell_format`, `key_by` must be a string if present (null and absent both mean unset) — a number or object there is 400 naming the key.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Every key is optional; an empty object `{}` lists the first 100 records unfiltered.",
                "properties": {
                  "filter": {
                    "description": "The §9 filter as a real object, or as a JSON string. `null` or `\"\"` means no filter. A JSON string that doesn't parse, or a value that parses to something other than an object, is 400.",
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/Filter"
                      },
                      {
                        "type": "string",
                        "description": "The same object, JSON-encoded — parsed with JSON.parse, exactly as the GET's query parameter is."
                      },
                      {
                        "type": "null",
                        "description": "No filter."
                      }
                    ]
                  },
                  "sort": {
                    "type": "string",
                    "description": "Same grammar as the GET's `sort`."
                  },
                  "search": {
                    "type": "string",
                    "description": "Same behaviour as the GET's `search`."
                  },
                  "fields": {
                    "type": "string",
                    "description": "Same comma-separated projection as the GET's `fields` — still a STRING here, not a list."
                  },
                  "page_size": {
                    "type": "integer",
                    "default": 100,
                    "minimum": 1,
                    "maximum": 1000,
                    "description": "Accepted as a real number here (a numeric string also works). Must be a whole number in 1…1000."
                  },
                  "bookmark": {
                    "type": "string",
                    "description": "Same opaque cursor as the GET's `bookmark`."
                  },
                  "cell_format": {
                    "type": "string",
                    "enum": [
                      "names",
                      "ids"
                    ],
                    "default": "names"
                  },
                  "key_by": {
                    "type": "string",
                    "enum": [
                      "name",
                      "id"
                    ],
                    "default": "name"
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A page of records — byte-for-byte the same shape as the GET.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecordPage"
                }
              }
            }
          },
          "400": {
            "description": "Body isn't a JSON object; a string-typed key given a non-string value; plus every 400 the GET lister can raise.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or a table outside this account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/records/{rid}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The table's id (`tbl_` + 12 characters).",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "rid",
          "in": "path",
          "required": true,
          "description": "The record's id (`rec_` + 12 characters).",
          "schema": {
            "type": "string",
            "examples": [
              "rec_7k2mnpq4rstv"
            ]
          }
        }
      ],
      "get": {
        "operationId": "getRecord",
        "summary": "Read one record",
        "description": "Returns one record with EVERY field — there is no `fields` projection on this route, and the companion map describes the whole table.\n\nA record in the trash reads as 404: the row is excluded by the same soft-delete guard the lister uses, so \"trashed\" and \"never existed\" are indistinguishable here.",
        "parameters": [
          {
            "name": "cell_format",
            "in": "query",
            "required": false,
            "description": "`names` (default) or `ids` for select cells. Any other value is 400.",
            "schema": {
              "type": "string",
              "enum": [
                "names",
                "ids"
              ],
              "default": "names"
            }
          },
          {
            "name": "key_by",
            "in": "query",
            "required": false,
            "description": "`name` (default) or `id` for the cell keys; also switches `field_ids` to `field_names`. Any other value is 400.",
            "schema": {
              "type": "string",
              "enum": [
                "name",
                "id"
              ],
              "default": "name"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The record plus the field map for the whole table.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "required": [
                        "record"
                      ],
                      "properties": {
                        "record": {
                          "$ref": "#/components/schemas/Record"
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/FieldKeyMap"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "A `cell_format` or `key_by` that isn't one of the allowed values.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table (or one outside this account), or no such live record — including a record that is in the trash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/records/{rid}/press": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The table's id (`tbl_` + 12 characters).",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "rid",
          "in": "path",
          "required": true,
          "description": "The record's id (`rec_` + 12 characters). Must be a live (non-trashed) record.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "pressButton",
        "summary": "Press a button field on one record",
        "description": "Performs a button field's configured action for one record. **A press never mutates the record** — a button holds no per-record value at all.\n\nTwo actions, two response shapes:\n• `link` — the button's URL template is resolved server-side against this record's own cells (each `{Field Name}` token is URL-encoded; an unknown token or an empty cell resolves to empty; a list or object cell collapses to a readable scalar). The resolved URL is RETURNED, not followed — opening it is the caller's job.\n• `webhook` — exactly one delivery is enqueued to the webhook the button borrows, bypassing trigger/condition matching but riding the same signing, retry and pause machinery as any other delivery. The payload carries the full record.\n\nThe button's own enable-condition is re-evaluated server-side, so a button that is disabled in the interface cannot be fired by a crafted call — it is refused with 400.\n\nRequires the `editor` role: a viewer can see a button but not press it.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "field_id": {
                    "type": "string",
                    "description": "The button field's id (`fld_…`). A field ID only — a field NAME is not resolved here, unlike everywhere else in the records API. Whatever arrives is run through `String(… ?? \"\")` before the lookup: absent or null becomes an empty string, and a number/object/list becomes its string form. Any of those simply fails to match a field and reads as 404.",
                    "examples": [
                      "fld_3n7pqrs2tuvw"
                    ]
                  }
                },
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The action that was performed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ButtonPressResult"
                }
              }
            }
          },
          "400": {
            "description": "The body isn't a JSON object; the button's enable-condition does not match this record (\"This button isn't available for this record.\"); the button has no URL or no webhook configured; or its webhook no longer exists, is turned off, or is paused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, an unrecognised one, or a suspended account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A `viewer` member (or read-only support access): \"Viewers can't make changes.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table (or one outside this account); no field with that id ON THIS TABLE, or a field that exists but is not a button (both read as \"Button … not found.\"); or no such live record.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; see the listing operation.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled server error (for example a Postgres error the write path doesn't translate, such as a value too large for its column). The envelope is exactly {\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}} — never the underlying message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/trash": {
      "get": {
        "operationId": "listTrash",
        "summary": "List a table's trashed records",
        "description": "The records of this table that have been deleted but not yet purged, newest-trashed first (`deleted_at DESC, id DESC`).\n\nDeleting a record does not remove it: `DELETE`/`delete` writes a `deleted_at` stamp and the row stays in its real Postgres table, attachments still in the locker. This endpoint is the only read path that shows those rows; every other reader excludes them.\n\nThings this list deliberately does NOT do, because the handler reads exactly two query parameters and passes nothing else on:\n• There is no `filter`, `sort`, `search`, `fields`, `view`, `cell_format` or `key_by` here — sending them changes nothing. Cells are always keyed by field NAME and select cells always read as choice names.\n• Every field of the table is returned, in `position` order; there is no projection.\n• The response carries no `field_ids`/`field_names` companion map (unlike the live records list).\n\nRole: any member of the database may list the trash, at viewer level, and sees EVERY trashed record — the creator-scoping that governs restore and purge does not narrow this list. A 403 is therefore not reachable on this route: a non-member (or a wrong-tenant table id) gets 404, and every member is at least a viewer. An admin holding a support-access grant reads it as a viewer too.\n\nRetention: trashed records are hard-purged by an hourly background sweep once they are older than the retention window (7 days; `TRASH_RETENTION_MINUTES` overrides it for development). That window is enforced ONLY by the sweep — never by this route — so a record past 7 days that the sweep has not reached yet still appears here and can still be restored. The sweep takes at most 100 rows per table per pass, so a large backlog drains over successive hours.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string"
            },
            "examples": {
              "table": {
                "value": "tbl_4m8qrstu3vwx"
              }
            }
          },
          {
            "name": "page_size",
            "in": "query",
            "required": false,
            "description": "How many records to return. A whole number between 1 and 1000; anything else (a fraction, 0, 1001, a non-numeric string) is refused with 400. Absent, null or the empty string means the default.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 100
            }
          },
          {
            "name": "bookmark",
            "in": "query",
            "required": false,
            "description": "The `bookmark` from the previous page. Keyset paging on `(deleted_at, id)` — its own small cursor, unrelated to the records list's bookmark, and not interchangeable with it. Opaque base64url; a value that does not decode as JSON is refused with 400 (`\"bookmark\" isn't one of ours.`). Forward-only: there is no previous-page cursor.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of trashed records. `bookmark` is present ONLY when another page exists — its absence is the end-of-list signal (the handler fetches page_size + 1 rows to know).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrashList"
                }
              }
            }
          },
          "400": {
            "description": "`page_size` out of range or not a whole number, or a `bookmark` that isn't ours.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key. Also returned when the key's owner has been suspended, and when only a session cookie is presented — these REST routes accept a bearer key and nothing else.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table, or a table in a database this key's owner is not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. Carries a `Retry-After` header in whole seconds. 600 requests per minute per verified key, behind an address-keyed shield of 3000/min with a bearer (60/min without one).",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled failure. Always exactly `{\"error\":{\"code\":\"internal\",\"message\":\"Something went wrong.\"}}` — no detail is ever leaked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/trash/restore": {
      "post": {
        "operationId": "restoreRecords",
        "summary": "Restore trashed records",
        "description": "Clears `deleted_at` on the named records and returns them as ordinary live records.\n\nAll-or-nothing: the whole batch runs in one transaction, and if ANY id is not currently in the trash (already live, purged, or never existed) the request is refused with 404 naming the missing ids and nothing is restored. Duplicate ids in the list are collapsed before anything is checked.\n\nWhat comes back to life: attachments (their bytes never left the locker) and links (the connector cards are untouched by trashing, so any link whose partner still exists reads as before). A restored record's `updated_at` is NOT bumped — trashing did not touch it either, so a concurrency token held from before the record was trashed still matches afterwards.\n\nWebhooks: a restore fires `record.created` for each restored record — there is no dedicated restore event. As with every write path, a batch of 50 or more records skips webhook delivery entirely (only the scheduled `record_matches` watcher is told about them).\n\nRole: editor or above. A viewer is refused with 403 (`Viewers can't change records.`), and an admin reading through a support-access grant with 403 (`Support access is read-only.`). A member who is an editor rather than an owner may restore ONLY records they created themselves — an editor must not reverse an owner's deliberate trashing — and a record created before memberships existed (`created_by` empty) counts as nobody's, so only an owner can restore it. The check runs inside the transaction and names the offending ids.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrashIdsRequest"
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string"
            },
            "examples": {
              "table": {
                "value": "tbl_4m8qrstu3vwx"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Every named record, restored and read back inside the same transaction, in the order the (de-duplicated) `ids` list gave them — not the order Postgres restored them. Cells are keyed by field name; select cells read as choice names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "records"
                  ],
                  "properties": {
                    "records": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Record"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body is not valid JSON, or not a JSON object; or `ids` is absent, not a list, empty, longer than 100, or contains something that is not a `rec_`-prefixed string.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key, or a suspended owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A member whose role is too low: a viewer, a support-access reader, or an editor naming a record somebody else created. Nothing is restored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table (or not yours), or one or more ids are not in the trash — the message lists them and ends `Nothing was restored.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled failure — including one a caller can genuinely trigger. A unique field's index is PARTIAL (`WHERE deleted_at IS NULL`) so that a trashed record never blocks a live one from reusing its value; a value freed by trashing may therefore have been taken since. Restoring re-enters the record into that index and violates it, and unlike create and update this path is not wrapped in the unique-violation translator — so the clash surfaces as a bare `internal` 500, not the named 400 those paths give. The transaction rolls back: nothing is restored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/trash/purge": {
      "post": {
        "operationId": "purgeTrash",
        "summary": "Permanently delete trashed records, or empty the trash",
        "description": "Hard-deletes records that are ALREADY in the trash — the row, its connector cards, its attachment catalog rows and the attachment bytes in the locker. There is no undo; law 8's export and backups are the only recovery.\n\nTwo shapes, chosen by the body:\n• `{\"ids\": [\"rec_…\"]}` — purge exactly these, all-or-nothing, capped at 100. A live (not trashed) record is never purgeable: the SQL itself carries a `deleted_at IS NOT NULL` guard, so naming one is reported as 404 `Not in the trash` rather than deleting it.\n• `{\"all\": true}` — empty the trash. This is a LOOP, not a batch: it purges 100 rows at a time until the trash is empty, with no cap on the total, and each batch commits on its own. It returns a count, not records, and if it fails midway the batches already committed stay purged.\n`all` is checked first and must be exactly boolean `true`; a body with neither key (or with `all` set to anything else and no `ids`) is refused with 400.\n\nWebhooks: a purge fires nothing at all. `record.deleted` already fired when the record was trashed, and the same is true of the retention sweep.\n\nLocker bytes are removed AFTER the transaction commits and best-effort: a storage failure is swallowed, so a purge always reports success once the data rows are gone. Note what that costs — deleting the bytes and deleting the `catalog.attachments` rows are one step, bytes FIRST, so a locker failure strands both the bytes and their catalog rows, and nothing ever collects them: the locker has no garbage collector. The response cannot distinguish this from a clean purge.\n\nRole: editor or above (viewer → 403 `Viewers can't change records.`, support access → 403 `Support access is read-only.`). The creator rule is enforced differently by the two shapes, and the difference is visible: with `ids`, an editor naming a record created by someone else is REFUSED with 403 and nothing is purged; with `all`, an editor's empty-trash silently purges only their own trashed records and leaves everyone else's in place (the count reflects that), while an owner's empties everything. Records created before memberships existed count as nobody's and only an owner can purge them.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PurgeRequest"
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table's id (`tbl_` + 12 characters).",
            "schema": {
              "type": "string"
            },
            "examples": {
              "table": {
                "value": "tbl_4m8qrstu3vwx"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Either the per-id acknowledgement (`records`) or the empty-trash count (`deleted`) — never both; which one you get is decided by the body you sent.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/PurgeResult"
                    },
                    {
                      "$ref": "#/components/schemas/EmptyTrashResult"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The body is not valid JSON or not a JSON object; neither `ids` nor `\"all\": true` was provided; or `ids` is not a list, is empty, is longer than 100, or holds something that is not a `rec_`-prefixed string.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key, or a suspended owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "A viewer, a support-access reader, or (with `ids`) an editor naming a record somebody else created. Nothing is purged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such table (or not yours), or one or more ids are not in the trash — including ids that name a perfectly live record. The message lists them and ends `Nothing was deleted.` Only reachable with `ids`; `all` never 404s on emptiness, it returns `{\"deleted\": 0}`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited; `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "An unhandled failure.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/webhooks": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The database id (`db_` + 12 characters). Webhooks belong to a DATABASE, never to a table — a table-scoped webhook is one that carries a `table_id`, but it is still listed, created and deleted through its database.",
          "schema": {
            "type": "string"
          },
          "examples": {
            "database": {
              "value": "db_4m8npqr3stuv"
            }
          }
        }
      ],
      "get": {
        "operationId": "listWebhooks",
        "summary": "List a database's webhooks",
        "description": "Every webhook in the database, whole-database ones and table-scoped ones together, ordered by `created_at` ASCENDING (oldest first — the opposite of the delivery log). Takes no query parameters at all: there is no paging, no limit and no filter, so a database with many webhooks returns all of them in one array.\n\nRole: any member (viewer and up). A viewer sees every webhook, but each one's `secret` is replaced with an empty string — see the `Webhook.secret` note.",
        "responses": {
          "200": {
            "description": "The webhooks.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "webhooks"
                  ],
                  "properties": {
                    "webhooks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Webhook"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, OR it exists in a database you are not a member of. Non-membership always reads as 404, never 403 — knowing a webhook exists is itself member-only information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "summary": "Create a webhook",
        "description": "Registers an endpoint and returns the created webhook INCLUDING its full 48-character signing secret — this response is the same regardless of role because the caller must already be an editor to get here, so nothing is redacted.\n\nThe URL is checked before it is stored (`lib/urlguard.ts`): http(s) only, no `user:password@` credentials, port must be 80, 443 or 1024–65535, and the hostname must RESOLVE — a DNS lookup happens during this request — to a genuinely public address. Loopback, RFC1918, carrier-grade NAT, link-local/169.254 metadata, multicast and the IPv6 equivalents (including IPv4-mapped forms) are all refused with 400. A box may set `WEBHOOK_ALLOW_PRIVATE_URLS=true` to skip the address check entirely; on the hosted service it is not set.\n\nThe new webhook starts `is_active: true`, `consecutive_failures: 0`, `paused_at: null`. Nothing is delivered retroactively: only events that happen after creation can fire it.\n\nReturns 200, not 201.",
        "requestBody": {
          "required": true,
          "description": "Must be a JSON object; a non-object body, an array, or unparseable JSON is a 400.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The webhook.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "webhook"
                  ],
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/Webhook"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body was unparseable JSON, or was an array or a non-object; `url` was missing, blank or not a string; the URL carried `user:password@` credentials, used a scheme other than http/https, or a port outside 80/443/1024–65535; the hostname resolved to a non-public address; `trigger_type` was missing or not one of the four; `name`, `table_id` or `payload_key` was sent as something other than a string; `conditions` was not a plain object (an array counts as not-an-object); `watched_field_ids` was not a list of strings; `payload_key` was neither \"name\" nor \"id\"; `conditions` or a non-empty `watched_field_ids` were sent without a `table_id`; a condition named an unknown field, an operator its storage kind does not accept, or a lookup/rollup field; or a watched field id is not a field on that table, or names a formula/lookup/rollup/button field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member of the database but only a viewer — the message is exactly `Viewers can't change webhooks.` There is a second 403 path: an operator holding a support-access grant on this database is admitted read-only, and any write refuses with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The database is not yours (or does not exist), or `table_id` names a table that is not in THIS database — the message is `Table tbl_… not found in this database.` even when the table genuinely exists elsewhere.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/webhooks/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The webhook id (`whk_` + 12 characters).",
          "schema": {
            "type": "string"
          },
          "examples": {
            "webhook": {
              "value": "whk_6p2qrstu4vwx"
            }
          }
        }
      ],
      "get": {
        "operationId": "getWebhook",
        "summary": "Read one webhook",
        "description": "The whole stored configuration, including the health fields (`consecutive_failures`, `paused_at`, `last_status`, `last_attempt_at`) that the delivery worker maintains. Any member may read it; a viewer's copy has `secret` blanked to `\"\"`.",
        "responses": {
          "200": {
            "description": "The webhook.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "webhook"
                  ],
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/Webhook"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, OR it exists in a database you are not a member of. Non-membership always reads as 404, never 403 — knowing a webhook exists is itself member-only information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateWebhook",
        "summary": "Update, pause/resume, or clear the failure streak",
        "description": "One endpoint doing three independent things, applied in this fixed order within a single request: (1) the configuration patch, (2) `is_active`, (3) `resume`. The response carries whichever of the three ran last, so sending a config patch together with `resume: true` returns the resumed row. When NONE of the three runs — the `\"resume\": false` case below — the response is a plain re-read of the webhook, and that read is viewer-level, so its `secret` obeys the viewer redaction.\n\nAt least one of the seven configuration keys, `is_active`, or `resume` must be present, otherwise 400. Every configuration key is optional and only what you send is written — this is a true partial update, not a replace.\n\nAN EXPLICIT `null` MEANS \"KEY ABSENT\" FOR EVERY KEY HERE EXCEPT `table_id`. `name`, `url`, `trigger_type`, `payload_key`, `conditions`, `watched_field_ids`, `is_active` and `resume` all read a JSON null as \"you didn't send this\" and leave the stored value untouched — a null on its own is therefore also NOT enough to satisfy the at-least-one-key rule. This bites hardest on the two nullable configuration columns: to widen a webhook back to every record send `\"conditions\": {}`, and to watch every field send `\"watched_field_ids\": []` — both mean the same thing to the evaluator as null. `table_id` is the one exception: an explicit `\"table_id\": null` IS honoured and re-scopes the webhook to the whole database.\n\nStale-configuration rules that make this stricter than it looks: changing `table_id` re-validates the ALREADY-STORED `conditions` and `watched_field_ids` against the new table, so moving a webhook to another table fails with 400 unless the same request also replaces (or empties) them. Widening a table-scoped webhook to the whole database fails the same way while conditions or watched fields are still stored, because neither can exist without a table.\n\nSide effect worth knowing for `record_matches` webhooks: changing `conditions`, `table_id` or `trigger_type` TO A DIFFERENT VALUE, or toggling `is_active`, or `resume`, wipes the clock watcher's memory of what already matched (`watch_seeded_at` is set back to null and the match table is emptied in the same transaction). The next pass silently re-adopts whatever matches then, so nothing that came due while the webhook was off or under the old terms is delivered in a burst. Re-sending an unchanged configuration — the ordinary idempotent-PUT idiom — is compared against what is stored and does NOT re-seed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The webhook.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "webhook"
                  ],
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/Webhook"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body was unparseable JSON, an array or a non-object; nothing recognisable to update (no configuration key, no `is_active`, no `resume` — the message is `Provide at least one field to update, \"is_active\", or \"resume\".`); a wrong type for one of the keys (a non-string `name`/`url`/`table_id`/`trigger_type`/`payload_key`, a non-boolean `is_active`/`resume`, a `conditions` that is not a plain object, a `watched_field_ids` that is not a list of strings); `trigger_type` not one of the four; `payload_key` neither \"name\" nor \"id\"; a `url` that failed the address guard; or stored or newly-sent conditions or watched fields that do not validate against the effective table (see the stale-configuration rules above).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member of the database but only a viewer (message: `Viewers can't change webhooks.`), or you are an operator holding a read-only support-access grant (message: `Support access is read-only.`).\n\nONE EXCEPTION: a body whose only recognised key is `\"resume\": false` never reaches an editor-gated function at all — it satisfies the \"send at least one key\" rule, then does nothing and simply re-reads the webhook — so a viewer gets 200 (with `secret` blanked), not 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The webhook is not yours or does not exist, or `table_id` names a table outside this webhook's own database.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook",
        "description": "Removed immediately and for good — there is no trash for a webhook and no typed-confirmation ritual at this layer. The delete cascades in Postgres to the webhook's whole delivery log and to the clock watcher's match memory, so the ledger this endpoint's siblings read disappears with it. A delivery already queued but not yet sent is dropped by the worker when it discovers the webhook is gone.",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "deleted"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "The id just deleted."
                    },
                    "deleted": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member of the database but only a viewer (message: `Viewers can't change webhooks.`), or you are an operator holding a read-only support-access grant (message: `Support access is read-only.`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, OR it exists in a database you are not a member of. Non-membership always reads as 404, never 403 — knowing a webhook exists is itself member-only information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/webhooks/{id}/secret": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The webhook id (`whk_` + 12 characters).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "regenerateWebhookSecret",
        "summary": "Regenerate the signing secret",
        "description": "Mints 24 fresh random bytes (48 hex characters) and stores them. There is no grace period and no second active secret: the previous one stops verifying the instant this returns, so any deliveries already in flight or queued will be signed with the NEW secret and a receiver still checking against the old one will reject them.\n\nThe request body is never read — send nothing.\n\nThe returned webhook always carries the new secret in full; this route does not apply the viewer redaction, because it is editor-only to begin with.",
        "responses": {
          "200": {
            "description": "The webhook.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "webhook"
                  ],
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/Webhook"
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member of the database but only a viewer (message: `Viewers can't change webhooks.`), or you are an operator holding a read-only support-access grant (message: `Support access is read-only.`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, OR it exists in a database you are not a member of. Non-membership always reads as 404, never 403 — knowing a webhook exists is itself member-only information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/webhooks/{id}/test": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The webhook id (`whk_` + 12 characters).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "sendWebhookTestDelivery",
        "summary": "Send a test delivery now, and report what the endpoint said",
        "description": "Unlike every other write here, this makes a REAL outbound HTTP request during the request and blocks on it, then hands back the raw attempt result — so this is how you find out whether your endpoint is reachable and your signature check works.\n\nWhat is sent: one signed POST with `event: \"webhook.test\"`, `test: true`, `record_id: null` and `changed_field_ids: []`. If the webhook targets a specific table, `after.fields` is built from that table's REAL field names with one plausible sample value per costume (so an automation platform's \"determine data structure\" step learns the true shape); with no table, or a table with no fields, the sample is the fixed `{\"example_field\": \"example value\", \"الحقل_مثال\": \"قيمة تجريبية\"}`. The cell keying honours the webhook's own `payload_key`, `before` is null.\n\nIt is deliberately unlike a real delivery in three ways: exactly ONE attempt (never retried whatever the outcome), it does NOT touch `consecutive_failures` and can never trigger the auto-pause, and it is sent even while the webhook is paused or `is_active: false`. It IS however a genuine row in the delivery log, so it shows up in `listWebhookDeliveries` and can be replayed.\n\nThe request body is never read.\n\nCRITICAL: a failed test still returns HTTP 200. The endpoint's failure is reported inside the body as `ok: false` — a non-2xx status, a DNS/TLS/connection error, a 10-second timeout, or a redirect (3xx is treated as failure; redirects are not followed). This includes the send-time address re-check: the URL is re-resolved and re-validated immediately before connecting (and the connection is pinned to that exact address), so a host that has since re-pointed at an internal address surfaces as `ok: false` with a message in `error`, NOT as a 400. Only Tabla-side problems produce a non-200 here.\n\nThe response keys are NOT in this API's usual snake_case: it returns `durationMs`, verbatim from the internal attempt result.",
        "responses": {
          "200": {
            "description": "The attempt was made. Read `ok` to learn whether the endpoint accepted it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookTestResult"
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member but only a viewer — causing real outbound traffic is a write, so the message is exactly `Viewers can't send deliveries.` An operator holding a read-only support-access grant is refused here too, with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, OR it exists in a database you are not a member of. Non-membership always reads as 404, never 403 — knowing a webhook exists is itself member-only information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/webhooks/{id}/deliveries": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The webhook id (`whk_` + 12 characters).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "listWebhookDeliveries",
        "summary": "Read the delivery log",
        "description": "The most recent 50 deliveries for this webhook, newest first by `created_at`. The 50 is FIXED IN THE HANDLER: there are no query parameters, no `limit`, no cursor and no paging, so anything you append to the URL is silently ignored and older deliveries are simply not reachable through this endpoint.\n\nThe log is also pruned in the background: `delivered`/`failed` rows older than 30 days are deleted, but the most recent 200 per webhook are always kept, so a quiet webhook never loses its whole history to the age cutoff. `pending`/`delivering` rows are never pruned.\n\nEach row carries the exact `payload` that was (or will be) POSTed, including record cell values — so a viewer, who cannot see the signing secret, can nonetheless read record data here. This is member-level (viewer and up) on purpose: it is the same data the viewer can already read through the records API.",
        "responses": {
          "200": {
            "description": "Up to 50 deliveries, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "deliveries"
                  ],
                  "properties": {
                    "deliveries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookDelivery"
                      }
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The resource does not exist, OR it exists in a database you are not a member of. Non-membership always reads as 404, never 403 — knowing a webhook exists is itself member-only information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/webhooks/{id}/deliveries/{deliveryId}/replay": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "IGNORED. The handler never reads this segment: authorisation and the target webhook are both taken from the delivery's OWN `webhook_id`. Any syntactically-present value — including a webhook id that does not exist, or one belonging to a webhook other than the delivery's — produces the same result. Send the real webhook id anyway; the routing may tighten.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "deliveryId",
          "in": "path",
          "required": true,
          "description": "The delivery to replay (`dlv_` + 12 characters). This is the only identifier the handler actually uses.",
          "schema": {
            "type": "string"
          },
          "examples": {
            "delivery": {
              "value": "dlv_8s3tuvwx5yza"
            }
          }
        }
      ],
      "post": {
        "operationId": "replayWebhookDelivery",
        "summary": "Queue a fresh copy of a past delivery",
        "description": "Inserts a NEW delivery row carrying the original's payload byte-for-byte except for `delivery_id`, which is the new row's id, and queues it. It does not edit or re-attempt the original, which stays in the log exactly as it was.\n\nUnlike the test endpoint, this returns as soon as the row is written — the send happens asynchronously. The worker is woken immediately via Postgres NOTIFY (a 15-second poll is the backstop), and from there the replay behaves like any ordinary delivery: HMAC-signed with the webhook's CURRENT secret, retried on 10s/60s/5min, and counting toward the auto-pause streak if it exhausts them. To learn the outcome, poll `listWebhookDeliveries` for the returned id.\n\nA replay is honest about time, not about content: the payload is frozen, so it still reports the record as it was, and any presigned attachment URLs inside it expired about an hour after the original was built and will 403 for the receiver.\n\nA replayed `webhook.test` payload still carries `test: true`.\n\nThe request body is never read.",
        "responses": {
          "200": {
            "description": "Queued. The new delivery has not been attempted yet.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "The NEW delivery's id (`dlv_` + 12 characters) — not the one you replayed.",
                      "examples": [
                        "dlv_2v7wxyza9bcd"
                      ]
                    }
                  },
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No `Authorization: Bearer <key_id>.<secret>` header, or the key is unknown, its secret does not match, or its owner's account is suspended. A session cookie is NOT accepted on these routes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "You are a member but only a viewer — causing real outbound traffic is a write, so the message is exactly `Viewers can't send deliveries.` An operator holding a read-only support-access grant is refused here too, with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No delivery with that id exists anywhere (`Delivery dlv_… not found.`), or it exists but belongs to a database you are not a member of — the two are indistinguishable by design.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over a rate limit: 600 requests per minute per verified API key, and an address-keyed shield of 3000/minute for credentialed traffic (60/minute with no bearer at all). Carries a `Retry-After` header in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tables/{id}/attachments": {
      "post": {
        "operationId": "uploadAttachment",
        "summary": "Upload one file into an attachment cell",
        "description": "One call, the file itself — a standard `multipart/form-data` upload carrying the bytes plus the field and record they belong to. Three things are true that a reader would not guess:\n\n1. **This call does NOT attach the file to the record.** It stores the bytes, writes a `catalog.attachments` row, and hands back the metadata. The cell is still empty until you `PATCH /api/tables/{id}/records` setting that attachment field to a list containing the returned `id`. Two steps, always.\n2. **The record must already exist and must not be in the trash.** The record lookup runs in the engine's default `exclude` deleted-mode, so uploading against a trashed record reads as `not_found`.\n3. **The response is the full `AttachmentMeta`**, thumbnails included — for an image, two WebP derivatives (256px grid, 1024px card, `fit: inside`, never enlarged) are generated during this same request and presigned alongside the original. Thumbnail generation is best-effort: if `sharp` cannot decode the bytes, or the file is not an image, `thumb_url`/`card_url` fall back to exactly `url` and the upload still succeeds.\n\nValidation order, all before any bytes reach the locker: table membership and role (editor and up — a viewer is refused), then the field (must be on this table and must be an attachment field), then the record, then size, then the two rate/quota checks. If the locker write succeeds but the catalog insert then fails, the just-written bytes (original and thumbnails) are purged best-effort before the error surfaces.\n\nSize is checked twice. The route rejects a declared `Content-Length` above 100 MB + 1 MB of multipart slack before buffering the body — a best-effort shield, since a client can omit or lie about the header. The real backstop is the parsed file's own size, checked against the flat 100 MB cap.\n\nTwo caps produce a 400 rather than a 429, which is worth knowing if you are writing retry logic: **more than 60 uploads per user per 60 seconds** (\"Too many uploads — wait Ns and try again.\") and **more than 5 GB of original attachment bytes across every table in the database** are both plain `bad_request`. The 429 on this route only ever comes from the shared per-address/per-key limiter in the route wrapper.\n\nOne sharp edge: a request body that is not parseable multipart makes `req.formData()` itself throw, and that throw is not an `ApiError` — so a wrong content type surfaces as the generic **500**, not a 400.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the file is being uploaded into (`tbl_` + 12 characters). The field and the record named in the body must both belong to it.",
            "schema": {
              "type": "string",
              "examples": [
                "tbl_4m8npqr3stuv"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "`multipart/form-data`. Any other content type does not parse as form data and fails as a 500 (see the description). All three parts are required; there is no way to upload without naming a field and a record.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "field_id",
                  "record_id"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The file itself. Its declared filename is stored verbatim as `filename` (Arabic and any other UTF-8 survives); a sanitised copy — path separators turned into `-`, control characters removed, leading dots stripped, trimmed, truncated to 180 characters, falling back to \"file\" if nothing survives — is used only inside the storage key. An empty filename is stored as \"file\". The part's own content type becomes `content_type`, defaulting to `application/octet-stream` when the client sends none — any MIME type is accepted. Capped at 100 MB (104857600 bytes)."
                  },
                  "field_id": {
                    "type": "string",
                    "description": "The attachment field this file belongs to (`fld_` + 12 characters). Must be a field of this table (else 404) whose `storage_kind` is `attachment` (else 400 naming the field). Must be a non-empty string.",
                    "examples": [
                      "fld_3n7pqrs2tuvw"
                    ]
                  },
                  "record_id": {
                    "type": "string",
                    "description": "The record it hangs on (`rec_` + 12 characters). Must be a live, untrashed row of this table. Must be a non-empty string.",
                    "examples": [
                      "rec_7k2mnpq4rstv"
                    ]
                  }
                }
              },
              "encoding": {
                "file": {
                  "contentType": "*/*"
                },
                "field_id": {
                  "contentType": "text/plain"
                },
                "record_id": {
                  "contentType": "text/plain"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored file's metadata, with presigned URLs. Note the presigner keeps an in-process cache: a URL is signed for 1 hour but reused for up to 50 minutes of that life, so a returned URL can already be part-spent and may have as little as ~10 minutes left. Nothing is attached to the record yet — see the description.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AttachmentMeta"
                }
              }
            }
          },
          "400": {
            "description": "A malformed or over-limit request: `file` is not a file part; `field_id` or `record_id` missing or empty; the named field is not an attachment field; the declared `Content-Length` exceeds 100 MB + 1 MB of multipart slack; the file itself exceeds 100 MB; more than 60 uploads by this user in the last 60 seconds; or this database has used its 5 GB of attachment storage.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, or one that does not resolve (unknown id, wrong secret, or a suspended account): \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" A session cookie does NOT satisfy this route — unlike the export route, this one goes through the wrapper's key-only gate.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Two distinct refusals, both from `catalog/authz.ts`: you are a member of this table's database but only a viewer — \"Viewers can't change records.\"; or you reached this database through an admin **support-access grant**, which is honoured at viewer rank only — \"Support access is read-only.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The table, the field, or the record was not found — including when the id belongs to another account (a wrong-tenant id is indistinguishable from a nonexistent one) and when the record is in the trash.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "The shared limiter in the route wrapper: 60/min per address with no bearer, 3000/min per address with one, and 600/min per verified key. Carries a `Retry-After` header in whole seconds. The per-user upload cap is NOT this status — it is a 400.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "Any failure that is not one of the named refusals — a body that is not valid multipart, a locker write that fails, a catalog insert that fails — surfaces as the generic `{ error: { code: \"internal\", message: \"Something went wrong.\" } }`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/export": {
      "get": {
        "operationId": "exportDatabase",
        "summary": "Download the whole database as a zip",
        "description": "The \"data is never hostage\" door: one zip holding `manifest.json`, one CSV per table, `attachments.csv`, an `attachments/` folder of the real file bytes, and a `README.txt`. The response body is the zip itself, not JSON — `Content-Type: application/zip`, `Content-Disposition: attachment; filename=\"<database name>-export.zip\"` (each of `/ \\ ? % * : | \" < >` in the name is REPLACED with a hyphen, not removed; the result is trimmed and falls back to \"table\" when nothing survives).\n\n**This is the one route in the group that accepts a session cookie as well as a bearer API key.** It is declared `auth: false`, which turns off the wrapper's key-only gate; ownership is then enforced inside by `requireDatabaseOwner`, which is the *any-member* (viewer-rank) check every read uses, resolving whichever credential is present. The reason is the in-app \"Export database…\" button: it fetches this URL directly and streams the response into a Blob, because round-tripping a multi-hundred-megabyte zip base64-encoded through a Server Action held the bytes in memory three to four times over and could OOM the container.\n\nAccepting a cookie opens a CSRF-shaped hole — a hostile page could top-level-navigate a logged-in browser here to force a download — so the route refuses any request whose `Sec-Fetch-Site` header is `cross-site` or `same-site`. Only `same-origin` (the in-app fetch) and `none` (an address-bar navigation) pass, and non-browser clients (curl, Make, n8n) send no such header at all and are unaffected. **This is the only 403 this route can produce**: the check needs only viewer rank, which every member has — and an admin holding a support-access grant is admitted at exactly viewer rank too, so even that path cannot 403 here.\n\nOne more consequence of `auth: false`: the verified-per-key budget (600/min) is never charged here. Only the address-keyed shield applies.\n\nWhat is inside, exactly:\n\n• **manifest.json** — `exported_at`, `{id, name}` for the database, and for each table `id`, `name`, `display_field_id`, and each field's `id`, `name`, `storage_kind`, `costume`, `options`. Not `operators`, not `physical_name`, not `position`. Tables appear in `position`, then `created_at` order.\n• **`<table name>.csv`** — a UTF-8 BOM (so Excel opens it correctly), then a header row of `id` followed by every field name in grid order, then every row of the table, paged 1000 at a time until exhausted. **Trashed records are NOT exported** — the read runs in the engine's default `exclude` deleted-mode, so anything sitting in the M15 trash is absent from the CSV. Cells are CSV-quoted; a value beginning `=`, `@`, TAB or CR is ALWAYS prefixed with `'` so a spreadsheet opens it as text, and a value beginning `+` or `-` is prefixed too unless the whole string is a signed number. Colliding table names get \" (2)\", \" (3)\" suffixes. A link cell writes the linked records' *display* text (falling back to the `rec_…` id when there is none), joined with a bare comma; an attachment cell writes bare-comma-joined `att_…` ids; any other multi-value cell joins with \", \" (comma-space, the exact delimiter the tabular importer splits on); a duration writes as `h:mm`/`h:mm:ss`, not raw seconds; date-and-time columns are UTC ISO instants, which is what the API stores but NOT what the app shows if the database's time zone is not UTC.\n• **attachments.csv** — a UTF-8 BOM, then `id, record_id, filename, size_bytes, path`. This listing is built straight from `catalog.attachments` with no trash filter, so it can name attachments belonging to records the table CSVs no longer contain.\n• **attachments/** — every file's real bytes, named `<att id>-<sanitised filename>`, **only while the database's total attachment bytes are at or below 500 MB**. Over that ceiling every file exports metadata-only: its `path` column is blank and the README names the count and the megabytes skipped. A file the locker no longer holds also exports as a blank `path` and is named in the README, rather than breaking the zip.",
        "security": [
          {
            "bearerAuth": []
          },
          {}
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The database to export (`db_` + 12 characters). The lookup is a literal id match with no prefix validation, so legacy `bas_…` rows resolve exactly the same way.",
            "schema": {
              "type": "string",
              "examples": [
                "db_2h5jkmn8pqrs"
              ]
            }
          },
          {
            "name": "Sec-Fetch-Site",
            "in": "header",
            "required": false,
            "description": "Not a parameter you set — browsers set it. `cross-site` and `same-site` are refused with 403; `same-origin`, `none`, and its complete absence all pass.",
            "schema": {
              "type": "string",
              "enum": [
                "same-origin",
                "same-site",
                "cross-site",
                "none"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The zip. Built entirely in memory and returned in one piece — expect a long first byte on a large database.",
            "headers": {
              "Content-Type": {
                "description": "Always `application/zip`.",
                "schema": {
                  "type": "string"
                }
              },
              "Content-Disposition": {
                "description": "`attachment; filename=\"<sanitised database name>-export.zip\"`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "Neither a valid session cookie nor a valid bearer key: \"Sign in, or provide a valid API key.\" Raised from inside the handler (by `getCurrentUserId`), not by the wrapper.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The browser-context guard only: `{\"error\":{\"code\":\"forbidden\",\"message\":\"Cross-site export requests are not allowed.\"}}` when `Sec-Fetch-Site` is `cross-site` or `same-site`. Role can never produce a 403 here.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or one you are not a member of — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "The address-keyed shield: 60/min with no bearer, 3000/min with one. The 600/min per-key budget is not charged on this route because it opts out of the wrapper's auth step. Carries `Retry-After` in whole seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "The generic internal error — for instance if zip generation itself fails. A missing locker object does not cause this; it is reported inside the zip instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/import": {
      "post": {
        "operationId": "importIntoDatabase",
        "summary": "Preview or run a CSV/XLSX import",
        "description": "One `multipart/form-data` route with two modes, chosen by which parts you send:\n\n• **`file` alone → preview.** The file is parsed and each column sniffed; you get back the row count, the guessed costume and options per column, and up to three sample values, so you can build a plan. **Nothing is written, and — worth knowing — the `{id}` in the path is not read at all on this branch: no database is looked up and no membership is checked.** Any caller with a valid API key can preview any file against any id, including one that does not exist.\n• **`file` + `target` + `plan` → run.** Membership is checked (editor and up), then the file is re-parsed from scratch (the preview is not cached — send the same bytes again) and the import executes.\n\nThe execution order is validate-everything-then-write, deliberately: the plan is checked, every target field resolved, every select choice collected, and **every cell of every row coerced** before the first field or record is created. A bad *value* on row 40,000 aborts with nothing written. **The one class of 400 this ordering cannot pre-empt is a UNIQUE-field clash**: a field with \"require unique values\" is enforced by a real Postgres index, so a duplicate is only discovered when the batch is actually written — mid-write, after earlier batches have already committed. What survives such a failure depends on the target:\n\n• `new_table` — the table this run created is deleted on any failure. If that cleanup delete itself fails the table is left behind and the failure is logged server-side; the original error still surfaces.\n• `existing_table` — records are written in batches of 100, each its own transaction, with no cross-batch rollback. If a failure strikes after some batches committed, the message is re-wrapped as a **400** (whatever the original status was) and appended with \"— N row(s) were already added before this failure and remain in the table.\"\n\nThe parser is chosen by the **filename extension** of the `file` part, not by sniffing bytes: `.xlsx`/`.xls` go to exceljs and read only the first worksheet (extra sheets are silently ignored, counted in `extraSheetsIgnored`); everything else is decoded as UTF-8 text and parsed as CSV by papaparse, which strips a leading UTF-8 BOM (so a Tabla export re-imports cleanly) and falls back to a comma when it cannot detect a delimiter — which is what makes single-column CSVs work.\n\nSize and shape caps: the route rejects a declared `Content-Length` above 22 MB (20 MB × 1.1) before buffering; the raw bytes are then checked against the flat 20 MB cap before parsing (an .xlsx is a zip, so its decompressed contents can exceed that — the row cap is the second line of defence); more than 50,000 data rows is refused, at preview time as well as at run time; and a select column that resolves to more than 100 distinct choices is refused by name rather than minting a hundred-plus-choice field.\n\nMapping and creation rules the plan must respect: only the 14 plain costumes are importable — link, attachment, and every formula/lookup/rollup costume are refused as both create and map targets. Mapping onto an existing select field **auto-adds** any choice values the file contains that the field lacks, preserving every existing choice's id and colour. Columns are identified by `columnIndex`, never by header text, so duplicate or blank headers cannot collapse into each other. A column with no plan entry, or one whose action is `skip`, is simply not imported.\n\nCell coercion, which is where most 400s come from: an empty or whitespace-only cell becomes null for every kind. Numbers strip thousands separators; a currency cell strips its symbol but is refused outright if it contains no digit at all (so \"TBD\" cannot become 0); a percent cell strips a trailing \"%\" under the same no-digit rule and is stored as the written number, not divided by 100; a duration cell is parsed in the target field's own `duration_format` (so a bare \"90\" reads as minutes in an `h:mm` field), refusing anything unparseable or negative. Checkbox accepts only true/1/yes/false/0/no, case-insensitively. A date already shaped `YYYY-MM-DD…` is taken as-is; `M/D/YYYY` is read **US-style, month first** — genuinely unresolvable from the value alone; anything else goes through `new Date()` and a date-only field then reads that instant's LOCAL calendar components. Multi-select cells split on comma-space or semicolon-space. Text and long-text cells are stored raw (untrimmed). Every coercion failure names the file line number, header row counted (row 1 of data is reported as \"Row 2\").\n\nAs on the upload route, a body that is not parseable multipart makes `req.formData()` throw a non-`ApiError` and surfaces as a generic **500**, not a 400.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The database to import into (`db_` + 12 characters). Read and checked only on the run branch; ignored entirely on the preview branch.",
            "schema": {
              "type": "string",
              "examples": [
                "db_2h5jkmn8pqrs"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "`multipart/form-data`. Send `file` alone for a preview, or all three for a run. Sending exactly one of `target`/`plan` is refused — it is both or neither.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The CSV or XLSX itself, capped at 20 MB of raw bytes. The part's filename decides the parser: `.xlsx`/`.xls` (case-insensitive) → Excel, anything else → CSV."
                  },
                  "target": {
                    "type": "string",
                    "description": "Run only. A JSON **string** (not a nested object) holding a `TabularImportTarget`. Must parse as JSON. Send it as a plain form field with NO `filename` in its Content-Disposition — a part carrying a filename arrives as a File rather than a string and is refused with the \"provide both\" 400.",
                    "contentMediaType": "application/json",
                    "contentSchema": {
                      "$ref": "#/components/schemas/TabularImportTarget"
                    }
                  },
                  "plan": {
                    "type": "string",
                    "description": "Run only. A JSON **string** holding an array of `TabularColumnPlan`. Must parse as JSON and must be an array. Same no-filename requirement as `target`.",
                    "contentMediaType": "application/json",
                    "contentSchema": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TabularColumnPlan"
                      }
                    }
                  }
                }
              },
              "encoding": {
                "file": {
                  "contentType": "*/*"
                },
                "target": {
                  "contentType": "text/plain"
                },
                "plan": {
                  "contentType": "text/plain"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A `TabularImportPreview` when only `file` was sent, or a `TabularImportResult` when `target` and `plan` were sent too. Which one you get is decided entirely by the parts present in the request.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/TabularImportPreview"
                    },
                    {
                      "$ref": "#/components/schemas/TabularImportResult"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The large family. Request shape: `file` is not a file part; only one of `target`/`plan` was sent (or one arrived as a file part rather than a string); either failed to parse as JSON; `plan` is not an array. Size and shape: declared `Content-Length` over 22 MB; the file over 20 MB; over 50,000 rows; a CSV with no rows at all; an XLSX with no sheets; headers but no data rows. Target: a `new_table` whose `name` is blank after trimming. Plan: a `columnIndex` outside the file's columns (named 1-based); a create action naming a non-importable costume; a new field with a blank name; two new fields with the same name; a new field colliding with one already on the table; a `map` action whose target field no longer exists or is computed; more than 100 distinct select choices. Data: any cell that will not coerce, prefixed \"Row N:\". Mid-write: a value that violates a target field's UNIQUE index (\"\\\"Field\\\" must be unique — \\\"value\\\" already exists.\"). Plus the structural caps reached through the shared catalog functions — 200 tables per database, 500 fields per table — and any `options` blob `createField` itself rejects. On an `existing_table` run that had already committed batches, the message discloses exactly how many rows remain.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No valid bearer key: \"Missing or invalid API key. Expected: Bearer <id>.<secret>.\" A session cookie does not work here — this route keeps the wrapper's key-only gate (only export opts out).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Run branch only — the preview branch checks no role at all. Either you are a member of the database but only a viewer (\"Viewers can't make changes.\", and the same refusal on the table when the target is an existing one), or you reached this database through an admin support-access grant, which is honoured at viewer rank only (\"Support access is read-only.\").",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Run branch only: no such database, or one you are not a member of; or an `existing_table` target that does not exist, is not yours, **or belongs to a different database than the one in the path** — that last case is deliberately reported as \"Table … not found.\", not as a mismatch.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Narrow and race-only, but reachable: the run branch checks new field names against a snapshot of the table taken before the coercion pass, while `createField` re-checks uniqueness at write time and raises `conflict` — so a field created concurrently between those two moments yields \"A field named \\\"…\\\" already exists in this table.\" There is no deterministic path to this status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "The shared limiter: 60/min per address with no bearer, 3000/min per address with one, 600/min per verified key. Carries `Retry-After` in whole seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "The generic internal error. Reachable when the body is not valid multipart; when the parser itself throws on malformed bytes — notably a real legacy `.xls` binary, or any file whose extension says xlsx but whose bytes are not a valid workbook (those throws are not `ApiError`s and are not translated); and when `target.mode` is neither of the two known values, since an unrecognised mode falls into the `new_table` branch and reading `.name` off a target that has none throws.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Is the box alive, and does the database answer",
        "description": "The one route that is NOT wrapped by the shared `route()` helper (app/src/app/api/health/route.ts is a bare Next handler). Consequences, all deliberate: no bearer key is read, no rate limiter runs, and a failure is NOT the standard `{error:{code,message}}` envelope — the same three-key body comes back either way, only the HTTP status changes. The check itself is one `SELECT 1` against the catalog pool; anything it throws is swallowed and reported as `database: false`, so this endpoint never 500s on a database outage, it 503s. `ok` is not a second signal — it is literally the same boolean as `database`.",
        "security": [],
        "responses": {
          "200": {
            "description": "The app is running and Postgres answered `SELECT 1`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "ok": true,
                  "name": "tabla",
                  "database": true
                }
              }
            }
          },
          "503": {
            "description": "The app answered but the `SELECT 1` threw — `ok` and `database` are both false. Not an `Error` envelope; there is no `code` and no reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "ok": false,
                  "name": "tabla",
                  "database": false
                }
              }
            }
          }
        }
      }
    },
    "/api/meta/whoami": {
      "get": {
        "operationId": "getWhoami",
        "summary": "Which account this key acts as, and which key it is",
        "description": "The cheap first call an integration makes when someone pastes a key in: it needs no database or table id, so it works before anything else is configured. There is deliberately no `valid` flag — reaching a 200 at all IS the proof, because the shared gate already verified the secret; a bad key is the shared 401.\n\nDeliberately small: no roles and no membership list. A key acts as its account, and what that account may touch is decided per resource, per request, so a blanket permission reported here would be a second answer that could disagree.\n\n`key.last_used_at` is the timestamp the credential resolver stamped on the way IN to this very request, so it always reads as roughly \"now\". It is included because the account's own key screen shows the same column, and confirming WHICH key an integration is using is exactly why someone calls this.\n\nOne subtlety with real consequences: the GATE admits nothing but an `Authorization: Bearer` API key (a browser session cookie alone is the shared 401), but the identity actually REPORTED is resolved session-cookie-first and only then from the key. A request that carries both — a valid session cookie for one account and a bearer key belonging to a DIFFERENT account — therefore reports the cookie's account in `user`, and `key` comes back null, because the key row is looked up with `user_id` pinned to that same reported account. Send the bearer header alone (the ordinary programmatic case) and `key` is always populated.",
        "responses": {
          "200": {
            "description": "The account behind the credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Whoami"
                },
                "example": {
                  "user": {
                    "id": "usr_7k2mnpq4rstv",
                    "email": "tarek@example.com",
                    "name": "Tarek Shalaby"
                  },
                  "key": {
                    "id": "key_3n7pqrs2tuvw",
                    "name": "Automation platform",
                    "created_at": "2026-07-11T09:14:02.118Z",
                    "last_used_at": "2026-07-22T15:31:44.902Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No bearer key, one whose secret does not verify, or one whose account has been suspended. Message: `Missing or invalid API key. Expected: Bearer <id>.<secret>.` A browser session cookie does not satisfy the public API gate.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds. Two independent budgets produce it: 600 requests/minute per VERIFIED key (charged only after the secret is proven, so a leaked key id cannot be used to lock its owner out), and an address-keyed shield charged before any credential is read — 3000/min from one address when a bearer header is present, 60/min when none is.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds until the window frees up; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/members": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The database's id — `db_` + 12 characters. Ids minted before the 2026-07 rename carry the old `bas_` prefix and still resolve.",
          "schema": {
            "type": "string"
          },
          "example": "db_7k2mnpq4rstv"
        }
      ],
      "get": {
        "operationId": "listDatabaseMembers",
        "summary": "Everyone who can open this database, plus your own role",
        "description": "Readable by ANY member — viewer included — because this same answer is what the app's own Members screen uses to decide which controls to draw, and REST must not disagree with it. The list is ordered by when each membership was created (the original owner first), never by name or role.\n\n`my_role` is the caller's own role. One case makes it read `viewer` for somebody who does not appear in `members` at all: an operator holding an active, unexpired, unrevoked support-access grant reads the database as a viewer, and such a grant is honoured read-only.\n\nA non-member — including a perfectly valid key belonging to another account — gets 404, never 403: a wrong-tenant id must read exactly like a nonexistent one.",
        "responses": {
          "200": {
            "description": "The access list and the caller's own role.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberList"
                },
                "example": {
                  "members": [
                    {
                      "user_id": "usr_7k2mnpq4rstv",
                      "email": "tarek@example.com",
                      "role": "owner",
                      "created_at": "2026-07-10T18:02:11.004Z"
                    },
                    {
                      "user_id": "usr_9q4rstuv2wxy",
                      "email": "layla@example.com",
                      "role": "editor",
                      "created_at": "2026-07-12T08:41:57.220Z"
                    }
                  ],
                  "my_role": "owner"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential. Message: `Missing or invalid API key. Expected: Bearer <id>.<secret>.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, OR the caller is not a member of it — the two are deliberately indistinguishable. Message: `Database <id> not found.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds. 600/min per verified key, plus the pre-credential address shield (3000/min with a bearer header, 60/min without).",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addDatabaseMember",
        "summary": "Attach an EXISTING account directly, with no email round trip",
        "description": "The REST-only direct add. It does NOT create an account and it sends nothing: if no account already carries that address the call is refused with 400 pointing at `POST …/invitations`, which is the route that mails a link and creates the account when it is opened.\n\nUnlike an invitation this path CAN grant `owner` — ownership is deliberately never grantable through a forwardable link.\n\nThe write is an upsert on (database, account), so calling it for somebody who is already a member changes their role instead of failing. The one guard is the last owner: demoting the database's only owner is refused with 400, so a database can never be left ownerless.\n\nThe address is trimmed and lower-cased before lookup and validated only loosely (`something@something.something`) — the real validation is whether an account exists.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddMemberBody"
              },
              "example": {
                "email": "layla@example.com",
                "role": "editor"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The membership row as it now stands. `created_at` is the ORIGINAL membership date when this call merely changed an existing member's role.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "member"
                  ],
                  "properties": {
                    "member": {
                      "$ref": "#/components/schemas/Member"
                    }
                  },
                  "additionalProperties": false
                },
                "example": {
                  "member": {
                    "user_id": "usr_9q4rstuv2wxy",
                    "email": "layla@example.com",
                    "role": "editor",
                    "created_at": "2026-07-12T08:41:57.220Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Any of: `\"email\" is required and must be a non-empty string.` · `\"role\" is required and must be a non-empty string.` (both checked as present, non-blank strings before anything else) · `\"role\" must be \"owner\", \"editor\", or \"viewer\".` · `That doesn't look like a real email address.` · `No account with that email yet — send an invitation instead (POST …/invitations).` · `A database needs at least one owner.` The two body-shape refusals come first, before the credential's role is even consulted: `Request body must be valid JSON.` (unparseable, including an empty body) and `Request body must be a JSON object.` (valid JSON that is null, an array, or a scalar).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Two different refusals share this status. A member whose role is below owner: `Only an owner can manage members.` And an operator holding an active support-access grant on this database — not a member row at all, admitted as a viewer — who is refused every owner-level call with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or the caller is not a member of it. Message: `Database <id> not found.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/members/{userId}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The database's id — `db_` + 12 characters (pre-rename `bas_` ids still resolve).",
          "schema": {
            "type": "string"
          },
          "example": "db_7k2mnpq4rstv"
        },
        {
          "name": "userId",
          "in": "path",
          "required": true,
          "description": "The member's ACCOUNT id — `usr_` + 12 characters, as returned by the member list. It is not an email address, and its shape is never checked: any string matching no membership row simply reads as `That person isn't a member of this database.`",
          "schema": {
            "type": "string"
          },
          "example": "usr_9q4rstuv2wxy"
        }
      ],
      "patch": {
        "operationId": "changeDatabaseMemberRole",
        "summary": "Change one member's role",
        "description": "Owner-only. Sending the role the member already holds is a genuine no-op — the code returns the same body without writing anything.\n\nThe reply is deliberately narrower than the one `POST …/members` returns: just `user_id` and `role`, no email and no `created_at`.\n\nThe last-owner guard applies here too: demoting the only owner (including yourself) is refused with 400 rather than leaving the database ownerless.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChangeRoleBody"
              },
              "example": {
                "role": "viewer"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The member's role as it now stands.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "member"
                  ],
                  "properties": {
                    "member": {
                      "$ref": "#/components/schemas/MemberRoleRef"
                    }
                  },
                  "additionalProperties": false
                },
                "example": {
                  "member": {
                    "user_id": "usr_9q4rstuv2wxy",
                    "role": "viewer"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`Request body must be valid JSON.` · `Request body must be a JSON object.` (null, an array or a scalar) · `\"role\" is required and must be a non-empty string.` — all three checked before the caller's role is, so a viewer sending a malformed body sees 400, not 403 · `\"role\" must be \"owner\", \"editor\", or \"viewer\".` · `A database needs at least one owner.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Two different refusals share this status. A member whose role is below owner: `Only an owner can manage members.` And an operator holding an active support-access grant on this database — not a member row at all, admitted as a viewer — who is refused every owner-level call with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Two different absences share this status: the database is unknown or the CALLER is not a member of it (`Database <id> not found.`), or the database is fine but `userId` names nobody in it (`That person isn't a member of this database.`). The second message is only ever reachable by an owner, so it leaks nothing across accounts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "removeDatabaseMember",
        "summary": "Remove a member's access",
        "description": "Owner-only, and it touches ACCESS only: every record the removed person created keeps its `created_by` stamp, nothing they made is deleted or reassigned, and re-adding them later restores exactly the same rights. An owner may remove themselves, provided another owner remains.\n\nThis is the one route in this group whose success body is not wrapped in a named key.",
        "responses": {
          "200": {
            "description": "The membership row is gone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberRemoved"
                },
                "example": {
                  "user_id": "usr_9q4rstuv2wxy",
                  "removed": true
                }
              }
            }
          },
          "400": {
            "description": "`A database needs at least one owner.` — removing the only owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Two different refusals share this status. A member whose role is below owner: `Only an owner can manage members.` And an operator holding an active support-access grant on this database — not a member row at all, admitted as a viewer — who is refused every owner-level call with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`Database <id> not found.` (unknown database, or caller not a member) or `That person isn't a member of this database.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/databases/{id}/invitations": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The database's id — `db_` + 12 characters (pre-rename `bas_` ids still resolve).",
          "schema": {
            "type": "string"
          },
          "example": "db_7k2mnpq4rstv"
        }
      ],
      "get": {
        "operationId": "listDatabaseInvitations",
        "summary": "Invitations sent but not yet accepted",
        "description": "Owner-only — unlike the member list, which any member may read — because it names the addresses of people who are not members yet.\n\nIt is a live window, not a history: accepted rows are excluded, and so are rows whose 7-day window has already passed (`expires_at > now()` is part of the query). An expired invitation therefore vanishes from this list while its row still exists — and can still be revoked by id.\n\nFive columns only. The invitation's secret hash is never exposed, and neither is the accept URL: that string exists only inside the email that was sent.",
        "responses": {
          "200": {
            "description": "Pending invitations, oldest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "invitations"
                  ],
                  "properties": {
                    "invitations": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Invitation"
                      }
                    }
                  },
                  "additionalProperties": false
                },
                "example": {
                  "invitations": [
                    {
                      "id": "inv_4m7pqrs2tuvw",
                      "email": "new@example.com",
                      "role": "viewer",
                      "expires_at": "2026-07-29T12:00:00.000Z",
                      "created_at": "2026-07-22T12:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Two different refusals share this status. A member whose role is below owner: `Only an owner can manage members.` And an operator holding an active support-access grant on this database — not a member row at all, admitted as a viewer — who is refused every owner-level call with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or the caller is not a member of it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "inviteDatabaseMember",
        "summary": "Invite an address by email, or update an existing member's role",
        "description": "Owner-only, with THREE honest outcomes distinguished by the two booleans it returns rather than by status:\n\n· the address already belongs to a member → their role is updated in place and NO email is sent (`{invited:false, role_updated:true}`) — re-inviting somebody who is already in IS the role change;\n· a pending invitation already exists for that address → the SAME row is reused with the new role, a fresh secret and a fresh 7 days, and one email goes out (`{invited:true, role_updated:false}`);\n· otherwise a new invitation row is created and one email goes out (same body).\n\nThe emailed link is single-use and lives 7 days; opening it creates the account if the address has none, adds the membership, and signs the recipient in — the same trust a magic link carries. The link is never returned by the API, only mailed, and its accept URL is built from the server's own `PUBLIC_URL`, never from anything the caller sends.\n\n`role` here is narrower than on `POST …/members`: `editor` or `viewer` only. Ownership is granted in-app by an existing owner, never through a forwardable link.\n\nTwo caps and one ordering worth knowing. Sends are capped at **20 per hour per sending account**, a budget shared by this route and the app's own Members screen so a raw API caller cannot out-spam the UI — and exceeding it is reported as **400**, not 429, with `Invitation limit reached for now. Try again in about N min.` That budget is charged BEFORE the role and the address are validated, so even a call that is about to be rejected spends one of the twenty. Finally, if the address already belongs to an OWNER and the invite would demote them, the last-owner guard applies here too.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InviteBody"
              },
              "example": {
                "email": "new@example.com",
                "role": "viewer"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Which of the three outcomes happened. Exactly one of the two booleans is true.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InviteResult"
                },
                "example": {
                  "invited": true,
                  "role_updated": false
                }
              }
            }
          },
          "400": {
            "description": "In the order the code checks them: `Request body must be valid JSON.` · `Request body must be a JSON object.` (null, an array or a scalar) · `\"email\" is required and must be a non-empty string.` · `\"role\" is required and must be a non-empty string.` · then, only once the caller is confirmed an owner, `Invitation limit reached for now. Try again in about N min.` (the 20/hour sender cap — note that this is a 400, not a 429) · `\"role\" must be \"editor\" or \"viewer\".` (this is what sending `owner` gets) · `That doesn't look like a real email address.` · `A database needs at least one owner.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Two different refusals share this status. A member whose role is below owner: `Only an owner can manage members.` And an operator holding an active support-access grant on this database — not a member row at all, admitted as a viewer — who is refused every owner-level call with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such database, or the caller is not a member of it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited` — the shared request budgets only. The invitation cap is NOT reported here; it is the 400 above.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Code `internal`, message `Something went wrong.` Reachable here in a way it is not on the read routes: the outbound mail send is AWAITED, and either a failed HTTPS call to the mail provider or a non-2xx from it throws a plain error. The invitation row has already been written at that point and is NOT rolled back — so a 500 from this route can leave a pending invitation whose link nobody ever received. Reissue by POSTing again (it reuses the same row with a fresh secret), or revoke it. This applies only to a deployment that has mail configured; where the mail credentials are unset the link is written to the server log instead and the call succeeds with `invited: true` even though no email left the box.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/invitations/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The invitation's id — `inv_` + 12 characters, as returned by the pending-invitations list. Note that no database id travels in this path; it is read off the invitation row itself.",
          "schema": {
            "type": "string"
          },
          "example": "inv_4m7pqrs2tuvw"
        }
      ],
      "delete": {
        "operationId": "revokeInvitation",
        "summary": "Revoke a pending invitation",
        "description": "Owner-only — of whichever database the invitation belongs to, looked up from the row before the role check runs. The row is deleted outright, and the emailed link stops working the instant it is, because redeeming verifies against that row.\n\nAn ALREADY-ACCEPTED invitation is refused with 400 instead of being deleted, pointing you at removing the member. An expired-but-unaccepted invitation CAN still be revoked, even though it no longer appears in the pending list.",
        "responses": {
          "200": {
            "description": "The invitation row is gone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvitationRevoked"
                },
                "example": {
                  "id": "inv_4m7pqrs2tuvw",
                  "revoked": true
                }
              }
            }
          },
          "400": {
            "description": "`That invitation was already accepted — remove the member instead.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unverifiable or suspended credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The caller is a member of the invitation's database but not an owner: `Only an owner can manage members.` Or an operator reading that database through a support-access grant, refused with `Support access is read-only.`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`Invitation <id> not found.` when no such row exists, or `Database <id> not found.` when the row exists but belongs to a database the caller is not a member of — the second still refuses to confirm anything about another account's data.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Code `rate_limited`, with `Retry-After` in whole seconds.",
            "headers": {
              "Retry-After": {
                "description": "Whole seconds; never below 1.",
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong. The message is deliberately generic; the detail is recorded server-side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer <key_id>.<secret>` — an API key minted from the account menu. The key acts as the account that owns it: owner, editor and viewer roles are enforced exactly as they are in the app."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "title": "Error",
        "description": "The one error envelope every route produces (app/src/lib/http.ts wraps each handler; app/src/lib/errors.ts defines the shape). An ApiError is serialised as `{ error: { code, message, ...details } }` — `details` is a free-form object merged into the SAME `error` object, not nested under a key. Today exactly one thing populates it: the 409 raised by the concurrency check in `updateRecords`/`upsertRecords`, which adds `records`. An unhandled (non-ApiError) exception becomes exactly `{ error: { code: \"internal\", message: \"Something went wrong.\" } }` with status 500. The 429 produced by the rate limiter is built directly in http.ts and also carries a `Retry-After` header (whole seconds).",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "A stable machine code. The shared constructors in lib/errors.ts emit: `bad_request` (400), `unauthorized` (401), `forbidden` (403), `not_found` (404), `conflict` (409). http.ts adds `rate_limited` (429) and `internal` (500). Three routes raise their own codes directly: `airtable_disconnected` (400), `airtable_refresh_conflict` (409), `import_cancelled` (409).",
                "examples": [
                  "bad_request",
                  "not_found",
                  "conflict",
                  "rate_limited",
                  "internal"
                ]
              },
              "message": {
                "type": "string",
                "description": "A human sentence naming the offending field/record/operator where the code knows it. Written for a person reading a payload; do not parse it."
              },
              "records": {
                "type": "array",
                "description": "Present ONLY on the 409 raised by the optimistic-concurrency check (`if_updated_at`). It carries each stale record AS IT IS NOW — read back inside the same transaction, cells keyed by field name, select cells as names — so a stale client can heal in place without a second request. `upsertRecords` can raise a 409 whose `records` is an empty array (a matching record was removed mid-batch).",
                "items": {
                  "$ref": "#/components/schemas/Record"
                }
              }
            },
            "additionalProperties": true
          }
        },
        "additionalProperties": false
      },
      "Record": {
        "type": "object",
        "title": "Record",
        "description": "One record exactly as `toApiRecords` (app/src/catalog/records.ts) builds it. Only four keys — there is no `table_id`, no `deleted_at`, no field metadata. `fields` carries one entry per VISIBLE field (all of them, or the subset named by a `fields=` projection), including fields whose value is null; a field is never omitted because it is empty. Cell keys are field NAMES by default and field IDS when the caller passes `key_by=id`; the list responses carry the companion `field_ids`/`field_names` map so either vocabulary can be translated without a second request.",
        "required": [
          "id",
          "created_at",
          "updated_at",
          "fields"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`rec_` + 12 characters.",
            "examples": [
              "rec_7k2mnpq4rstv"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 instant, always in UTC (`Date.toISOString()`)."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 instant, always UTC. This string IS the optimistic-concurrency token: send it back verbatim as a record's `if_updated_at` on an update and the batch is refused with 409 if anything moved. Note it also advances on records at the far end of a link write (a counterpart or stolen-from record), not only on the record directly edited."
          },
          "fields": {
            "type": "object",
            "description": "Cell values, keyed by field name (default) or field id (`key_by=id`). How each kind serialises:\n\n• text / long_text / email / phone / url / member / formula_text / button-less text — a JSON string, or null. A `member` cell is the member's EMAIL as a plain string (the account key), not an object; the ONE exception is the anonymous shared-view read, which replaces a member email with the member's display name (falling back to the email's local part) so no deliverable address leaves the box.\n• number / currency / percent / rating / duration / formula_number — a JSON number, or null (Postgres numeric arrives as a string and is converted back to a real number). A `duration` cell is a number of SECONDS; it is written as \"1:30\" or as seconds, but it always reads back as seconds.\n• date / formula_date — a date-only field reads as \"YYYY-MM-DD\"; an `include_time` field reads as a full UTC ISO instant. Never a Date object, never a localised string.\n• checkbox — true or false, or null when never set.\n• single_select — the choice NAME by default, the stored 8-character choice ID under `cell_format=ids`; null when empty. A cell pointing at a deleted choice falls back to reading as its raw id.\n• multi_select — an array of choice names (or ids under `cell_format=ids`); `[]`/null when empty. A `multi_member` cell is an array of member EMAIL strings and ignores `cell_format` entirely.\n• link — ALWAYS an array of `LinkedRecord` objects `{id, display}`, never a string and never a bare id, and `[]` (not null) when nothing is linked — including for a link that holds one record at most. Trashed linked records are excluded. Order is this side's own stored order.\n• attachment — an array of `AttachmentMeta` objects with freshly presigned URLs; `[]` when empty. Any stored id with no catalog row is silently dropped from the array.\n• lookup — a lookup riding a single link reads as one scalar (or null); riding a multi link it reads as an ARRAY of that scalar, one entry per linked record. A lookup that surfaces a select reads as display TEXT (a multi-select collapses to one comma-joined string, not an array of names); a lookup surfacing a LINK reads as an array of `LinkedRecord` (its `display` is \"\" rather than null when the surfaced record has no name); a lookup surfacing an ATTACHMENT reads as an array of `AttachmentMeta`, exactly like a real attachment cell.\n• rollup — one scalar. `count` reads as a number and is 0 (never null) when nothing is linked; `sum`/`avg`/`min`/`max` read as null when nothing is linked. `min`/`max` over a date column read as a date string.\n• button — always null. A button holds no per-record value; the key exists and carries nothing, and writing to it is refused.\n• formula fields of every kind read exactly like the storage kind they wear; writing to one is refused with 400.",
            "additionalProperties": {
              "description": "One cell. See the parent description for the per-kind shape.",
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "number"
                },
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                },
                {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LinkedRecord"
                  }
                },
                {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AttachmentMeta"
                  }
                }
              ]
            }
          }
        }
      },
      "LinkedRecord": {
        "type": "object",
        "title": "LinkedRecord",
        "description": "One entry of a link cell (and of a lookup that surfaces a link), as `readLinks` builds it. `display` is the linked record's own display field rendered as ONE line of text — a number as its digits, a select as its choice name, a multi-value field as its values joined by \", \". It is null when the target table has no usable display field (a link, an attachment, a checkbox, a computed lookup/rollup and a button all cannot name a record), or when that field is empty on the linked record.",
        "required": [
          "id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The linked record's id (`rec_` + 12 characters).",
            "examples": [
              "rec_7k2mnpq4rstv"
            ]
          },
          "display": {
            "type": [
              "string",
              "null"
            ],
            "description": "One line of display text, or null. The key is ABSENT (not null) for entries past a `link_display_cap` — absent means \"not fetched, count it, don't draw it\", which is deliberately different from a linked record that genuinely has no name. The public REST list never sets that cap, so every entry carries the key there."
          }
        },
        "additionalProperties": false
      },
      "Field": {
        "type": "object",
        "title": "Field",
        "description": "A field as the API hands it out (`toApiField` in app/src/catalog/fields.ts): the whole `catalog.fields` row, unfiltered, plus `operators`. The physical column name is deliberately still exposed in v0.",
        "required": [
          "id",
          "table_id",
          "name",
          "storage_kind",
          "costume",
          "options",
          "physical_name",
          "position",
          "created_at",
          "updated_at",
          "operators"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`fld_` + 12 characters.",
            "examples": [
              "fld_3n7pqrs2tuvw"
            ]
          },
          "table_id": {
            "type": "string",
            "description": "`tbl_` + 12 characters."
          },
          "name": {
            "type": "string",
            "description": "The human label, in any language. Unique within its table, so it can be used wherever a field is named by id — but it moves when someone renames the field."
          },
          "storage_kind": {
            "type": "string",
            "description": "What the real Postgres column holds. Nine kinds, and the ONLY thing that decides how a cell is stored, filtered and sorted. Several costumes share one kind (email/phone/url/member/button/formula_text/lookup_text are all `text`).",
            "enum": [
              "text",
              "long_text",
              "number",
              "date",
              "checkbox",
              "single_select",
              "multi_select",
              "link",
              "attachment"
            ]
          },
          "costume": {
            "type": "string",
            "description": "The presentation profile — what the field-type menu calls it, and the value `type` takes when creating a field. `link` has no column of its own (it lives in a connector table); `button` and every `lookup_*`/`rollup_*` also have no column (button is pure config, lookup/rollup are computed read-time across a link); `formula_*` costumes are real GENERATED columns.",
            "enum": [
              "single_line_text",
              "long_text",
              "email",
              "phone",
              "url",
              "number",
              "currency",
              "percent",
              "rating",
              "duration",
              "date",
              "checkbox",
              "member",
              "multi_member",
              "single_select",
              "multi_select",
              "link",
              "attachment",
              "button",
              "formula_number",
              "formula_text",
              "formula_date",
              "lookup_text",
              "lookup_number",
              "lookup_date",
              "lookup_checkbox",
              "rollup_number",
              "rollup_date"
            ]
          },
          "options": {
            "type": "object",
            "description": "The field's settings pouch — one flat JSON object, `{}` when nothing is set. It is deliberately NOT per-costume: a pouch outlives the costume that wrote it (changing a currency field to text keeps `symbol`/`decimals`), so a text field really can carry number settings. A write may only INTRODUCE a key from the known list; any key already stored is tolerated forever. Known keys: rich, unique, decimals, precision, separators, symbol, symbol_position, max, shape, color, duration_format, include_time, date_format, time_format, icon, checked_color, unchecked_color, choices, allow_multiple, picker_field_ids, target_table_id, direction, pair_field_id, reverse_field_name, expression, referenced_field_ids, link_field_id, target_field_id, aggregation, conditions, source_presentation, label, action, url_template, webhook_id, condition. Anything else is refused with 400 naming it.",
            "properties": {
              "choices": {
                "type": "array",
                "description": "Select costumes only. Always normalised to `{id, name, color}` — cells store the id, so renaming a choice touches zero records. Ids are 8 characters with NO prefix. `color` is null when the choice wears no colour. Writing a choice without an id mints one; writing one with an id keeps it.",
                "items": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "color"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "examples": [
                        "k4m7pq2r"
                      ]
                    },
                    "name": {
                      "type": "string"
                    },
                    "color": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              },
              "include_time": {
                "type": "boolean",
                "description": "Date costume only. Decides the real column type (timestamptz vs date) and is therefore frozen after the field is created — a later change is refused, even over raw REST."
              },
              "target_table_id": {
                "type": "string",
                "description": "Link costume only: the table this link points at. It must be in the same database, and the caller must be a member of it."
              },
              "direction": {
                "type": "string",
                "description": "Link costume only. Absent or anything other than \"reverse\" means this is the forward side (it owns from_id → to_id in the connector); \"reverse\" marks the auto-created mirror."
              },
              "allow_multiple": {
                "type": "boolean",
                "description": "Link costume only: whether the cell may hold more than one record. A reverse mirror is always effectively multiple."
              },
              "aggregation": {
                "type": "string",
                "description": "Rollup costumes only.",
                "enum": [
                  "sum",
                  "count",
                  "min",
                  "max",
                  "avg"
                ]
              },
              "conditions": {
                "type": "object",
                "description": "Lookup/rollup only: a §9 filter over the TARGET table, so only matching linked rows feed the computed value.",
                "$ref": "#/components/schemas/Filter"
              }
            },
            "additionalProperties": true
          },
          "physical_name": {
            "type": "string",
            "description": "The neutral name of the real object in the `data` schema — `f_` + 10 characters for a column, `l_` + 10 for a link's connector table. Still returned in v0. Never send it as a field reference; filters and projections take the id or the name.",
            "examples": [
              "f_3n7pqrs2tu"
            ]
          },
          "position": {
            "type": "integer",
            "description": "Display order within the table. Fields are always returned ordered by position, then created_at."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "operators": {
            "type": "array",
            "description": "The §9 filter operators this field ACTUALLY accepts — derived at response time from the same OPERATORS table the query builder itself checks against, so it can never advertise something the engine would refuse. Two cases return an EMPTY list: a `button` field (it holds no value), and a lookup/rollup that surfaces a select, link or attachment (it reads as display text with no column to compare, and the engine refuses it). Every other field returns its storage kind's row from the table in `Filter`.",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "is",
                "is_not",
                "contains",
                "not_contains",
                "empty",
                "not_empty"
              ]
            ]
          }
        },
        "additionalProperties": false
      },
      "Filter": {
        "type": "object",
        "title": "Filter",
        "description": "The §9 filter language (app/src/engine/query.ts). One object drives every reader — the REST list, a saved view, a webhook's conditions, a lookup's target filter. On a GET it travels as URL-encoded JSON in `filter=`; in a POST body it is a real object. Both are the same language. A member of `conditions` is either a leaf condition (`field`/`op`/`value`) or a nested GROUP carrying its own `join` and `conditions` — so \"A and (B or C)\" is sayable. Nesting is capped at 2 levels; deeper is refused with 400. A group with an empty `conditions` constrains nothing (it does not exclude every record). A flat filter remains exactly as valid as before. An absent/null filter, or one whose `conditions` is absent, null or empty, adds nothing at all. Anything else that is not an object is refused with 400.\n\nStrictness matters: the public REST list compiles STRICTLY — a condition naming a renamed/deleted field, or an operator the field's kind does not take, raises 400 naming it, rather than quietly returning nothing. (The app's own display surfaces compile leniently instead; that is not the REST behaviour.) The one deliberate exception even in strict mode is a select value that no longer resolves to a live choice: it silently matches nothing rather than failing, so a renamed or deleted choice cannot break a filter.\n\nA filter may also be written as an ARRAY of arrays — `[[A, B], [C]]` means \"(A and B) or C\". It is the same grammar spelled as nesting, and the shape condition builders converge on; a condition inside it may use `field`/`op`/`value` or the terse `a`/`o`/`b`. An empty array constrains nothing.",
        "properties": {
          "join": {
            "type": "string",
            "description": "How the conditions combine. Defaults to \"and\" when absent. Any other value is refused with 400.",
            "enum": [
              "and",
              "or"
            ],
            "default": "and"
          },
          "conditions": {
            "type": "array",
            "description": "A flat list. Must be a list if present (a non-list is 400); an empty list is treated as no filter.",
            "items": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/FilterCondition"
                },
                {
                  "$ref": "#/components/schemas/Filter"
                }
              ],
              "description": "A leaf condition, or a nested group (one level deep)."
            }
          }
        },
        "additionalProperties": true
      },
      "FilterCondition": {
        "type": "object",
        "title": "FilterCondition",
        "description": "One condition. `field` may name the field EITHER by its stable id (`fld_…`) OR by its human name, in any language — the id is tried first, then an exact name match. A name shared by two fields is refused with 400 telling you to use the id (field names are unique per table, so this only bites across a stale reference), and an unknown reference is refused with 400 naming it.\n\nOperator vocabulary, exactly as the engine allows it per STORAGE KIND (not per costume):\n• text, long_text: is, is_not, contains, not_contains, empty, not_empty\n• number: is, is_not, greater, greater_or_equal, less, less_or_equal, empty, not_empty\n• date: is, before, after, on_or_before, on_or_after, empty, not_empty\n• checkbox: is  (the ONLY operator on a checkbox)\n• single_select: is, is_not, any_of, none_of, empty, not_empty\n• multi_select: has_any, has_all, has_none, empty, not_empty\n• link: has_any, has_all, has_none, empty, not_empty\n• attachment: empty, not_empty  (nothing else)\nA field's own `operators` array is the authoritative per-field answer, since a button and a non-scalar lookup/rollup accept nothing at all.\n\nBehaviour worth knowing: text `contains`/`not_contains` are case-insensitive ILIKE, and a literal % or _ in the value is escaped, not treated as a wildcard. `not_contains` and `is_not` also match rows where the cell is NULL. Text `empty` covers both NULL and the empty string. `is false` on a checkbox matches never-set cells as well as explicit false. A date-and-time field compared against a bare \"YYYY-MM-DD\" is compared against that whole day in the DATABASE's timezone. A link filter excludes trashed linked records, matching what the cell renders. `has_all` de-duplicates the ids you send.",
        "required": [
          "field",
          "op"
        ],
        "properties": {
          "field": {
            "type": "string",
            "description": "A field id (`fld_…`) or the field's exact name. Required and must be a non-empty string.",
            "examples": [
              "fld_3n7pqrs2tuvw",
              "Status"
            ]
          },
          "op": {
            "type": "string",
            "description": "The operator. Required and must be a string. Must be one the field's storage kind allows, or the request is refused with 400 listing what is allowed.",
            "enum": [
              "is",
              "is_not",
              "contains",
              "not_contains",
              "greater",
              "greater_or_equal",
              "less",
              "less_or_equal",
              "before",
              "after",
              "on_or_before",
              "on_or_after",
              "any_of",
              "none_of",
              "has_any",
              "has_all",
              "has_none",
              "empty",
              "not_empty"
            ]
          },
          "value": {
            "description": "The comparison value. Its type is dictated by the field's storage kind, and a mismatch is a 400 naming the field:\n• text/long_text — a string.\n• number — a finite number (a numeric string is refused).\n• checkbox — true or false.\n• date — a string (\"YYYY-MM-DD\", or a full timestamp for an include_time field), OR a relative object `{relative: \"today\"|\"now\", offset?: <whole number, |offset| ≤ 366>, unit?: \"day\"|\"week\"|\"month\"}` which is resolved to that literal before any comparison, once per request so two conditions cannot straddle midnight. No other key is allowed inside a relative value.\n• single_select — a choice id, or a choice name (both resolve; the id is rename-proof). `any_of`/`none_of` take a LIST of them.\n• multi_select — a list of choice ids or names.\n• link — a list of `rec_…` ids. Anything that is not a `rec_`-prefixed string is a 400.\n• `empty`/`not_empty` ignore the value entirely; omit it. A value is read for what it unambiguously says rather than the JSON type it wears, because a query string carries only text and a condition builder offering one input box per row cannot know the field is numeric: \"100\" is accepted for a number, \"true\"/\"false\" for a checkbox, and a single value where a list is expected is read as a list of one. Anything genuinely unreadable is still refused with 400 — \"abc\" is not a number, an empty string is not zero, and only the exact words true/false are read as a boolean.",
            "examples": [
              "Paid",
              1500,
              true,
              [
                "rec_7k2mnpq4rstv"
              ],
              {
                "relative": "today",
                "offset": -7,
                "unit": "day"
              }
            ]
          }
        },
        "additionalProperties": true
      },
      "AttachmentMeta": {
        "type": "object",
        "title": "AttachmentMeta",
        "description": "One file in an attachment cell, as `presignAttachmentMeta` (app/src/catalog/attachments.ts) builds it. Every read path goes through that one function, so all three URLs are always present on the wire even where the internal type marks the thumbnails optional — a consumer can still safely read `thumb_url ?? url`. The URLs are S3-style presigned GETs valid for about an hour: they are minted fresh on every read, so a payload held open longer than that (or an old webhook delivery replayed) carries expired links. The original's URL forces a download (Content-Disposition: attachment, RFC 6266, so a UTF-8 filename such as an Arabic one survives); the thumbnail URLs do not, since they are only ever drawn in an image tag.",
        "required": [
          "id",
          "filename",
          "size_bytes",
          "content_type",
          "url"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`att_` + 12 characters.",
            "examples": [
              "att_9q4rstuv2wxy"
            ]
          },
          "filename": {
            "type": "string",
            "description": "The original filename as uploaded, preserved verbatim (the sanitised form is used only for the locker key)."
          },
          "size_bytes": {
            "type": "integer",
            "description": "Size of the ORIGINAL file in bytes, as a real number (the catalog stores it as a string; it is converted here). Uploads are capped at 100 MB."
          },
          "content_type": {
            "type": "string",
            "description": "The MIME type recorded at upload.",
            "examples": [
              "image/jpeg",
              "application/pdf"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Presigned URL for the full-size original — what a download or a lightbox uses. ~1 hour of life."
          },
          "thumb_url": {
            "type": "string",
            "format": "uri",
            "description": "Presigned URL for the 256px WebP grid thumbnail. Falls back to exactly `url` when no derivative exists (a non-image, or a file predating the thumbnail backfill)."
          },
          "card_url": {
            "type": "string",
            "format": "uri",
            "description": "Presigned URL for the 1024px WebP card thumbnail, used by gallery cards. Same fallback rule as `thumb_url`."
          }
        },
        "additionalProperties": false
      },
      "Database": {
        "type": "object",
        "title": "Database",
        "description": "A database — the top of the tree (databases → tables → fields → records). Every route in this group hands out the raw `catalog.databases` row, unfiltered, so the internal ordering and attribution columns travel with it. Four of the settings columns (`icon`, `timezone`, `week_start`, `weekend`) are READ-ONLY over REST: they are returned here but no REST route accepts a write to them.",
        "required": [
          "id",
          "name",
          "color",
          "icon",
          "timezone",
          "week_start",
          "weekend",
          "position",
          "owner_id",
          "created_by",
          "updated_by",
          "archived_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`db_` + 12 characters.",
            "examples": [
              "db_7k2mnpq4rstv"
            ]
          },
          "name": {
            "type": "string",
            "description": "The human label, in any language. Not unique, not length-limited, and stored with whatever whitespace was sent."
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "The accent that tints the database's own chrome in the app, or null for the neutral default. In practice one of the twelve brand pigments — but note there is no CHECK constraint on the column and the storage function itself validates nothing: the twelve-name check lives only in the PATCH route, so this is NOT modelled as a closed enum on the read side. Treat an unrecognised value as the neutral default rather than an error.",
            "examples": [
              "nile",
              "violet",
              null
            ]
          },
          "icon": {
            "type": [
              "string",
              "null"
            ],
            "description": "A short key naming one glyph from the app's curated icon set, or null for the default mark. No REST route writes it. It is stored trimmed and truncated to 40 characters by the engine, and it is NOT validated against the set — an unrecognised key simply renders as the default."
          },
          "timezone": {
            "type": "string",
            "description": "An IANA zone name. Load-bearing, not cosmetic: every date-and-time surface — grid display, the cell editor, §9 date filters, the calendar view, webhook conditions, colour rules — resolves a stored UTC instant into a calendar day and wall clock in THIS zone, so they all agree which day a record falls on. Date-only fields carry no time and are unaffected. Defaults to `\"UTC\"`; no REST route writes it.",
            "default": "UTC",
            "examples": [
              "UTC",
              "Africa/Cairo"
            ]
          },
          "week_start": {
            "type": "integer",
            "description": "The ISO day number the week begins on: 1 = Monday … 7 = Sunday. Used by calendar surfaces. Defaults to 1. No REST route writes it.",
            "minimum": 1,
            "maximum": 7,
            "default": 1
          },
          "weekend": {
            "type": "array",
            "description": "ISO day numbers (1 = Monday … 7 = Sunday) drawn as the weekend. May be empty (no weekend shading at all) and never holds more than six days. Defaults to `[6, 7]`. No REST route writes it.",
            "items": {
              "type": "integer",
              "minimum": 1,
              "maximum": 7
            },
            "default": [
              6,
              7
            ]
          },
          "position": {
            "type": "integer",
            "description": "Display order. Assigned at creation as one past the highest position across ALL databases on the box, not per user, so the numbers a single account sees are typically sparse and non-contiguous. No route in this group changes it."
          },
          "owner_id": {
            "type": "string",
            "description": "`usr_` + 12 characters: the recorded root owner (this is what the 100-database creation cap counts). Distinct from membership — access is decided by the member rows, not by this column. Never null: migration 010 made the column NOT NULL after a one-time backfill, and it fails loudly at boot rather than guess if any row is still unowned."
          },
          "created_by": {
            "type": "string",
            "description": "`usr_` id of whoever created it."
          },
          "updated_by": {
            "type": "string",
            "description": "`usr_` id of whoever last changed it — including structural work INSIDE it (creating, renaming or deleting a table also bumps this and `updated_at`), not only edits to the database's own name or colour."
          },
          "archived_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Set when the database is soft-archived; null while active. An archived database keeps all its data and is still returned by `listDatabases` — nothing filters it out for you. No REST route archives or restores."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 UTC."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 UTC. Sending both `name` and `color` in one PATCH bumps it twice."
          }
        },
        "additionalProperties": false
      },
      "Table": {
        "type": "object",
        "title": "Table",
        "description": "A table's catalog row on its own, with no `fields` array. Only `renameTable` returns this bare shape; every other table-shaped response is a `TableWithFields`.",
        "required": [
          "id",
          "database_id",
          "name",
          "physical_name",
          "display_field_id",
          "position",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`tbl_` + 12 characters.",
            "examples": [
              "tbl_3n7pqrs2tuvw"
            ]
          },
          "database_id": {
            "type": "string",
            "description": "The database it belongs to. A link field may only ever point at a table in this same database."
          },
          "name": {
            "type": "string",
            "description": "The human label, in any language. Not unique within its database."
          },
          "physical_name": {
            "type": "string",
            "description": "The neutral name of the real Postgres table in the `data` schema — `t_` + 10 characters, deliberately carrying no trace of the human name. Still exposed in v0. It is not accepted anywhere as a reference; every route takes the `tbl_` id.",
            "examples": [
              "t_3n7pqrs2tu"
            ]
          },
          "display_field_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The field whose value names a record wherever one is shown as a chip (a link cell, search, a panel title). Null until one is set. The field must hold a plain one-line value — a link, an attachment, a checkbox, a computed lookup/rollup and a button are all refused as a display field. No route in this group sets it."
          },
          "position": {
            "type": "integer",
            "description": "Display order within its database, assigned as one past the highest position in that database. Tables are always returned ordered by position, then created_at."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "TableWithFields": {
        "title": "TableWithFields",
        "description": "A table plus its full, ordered field list — the shape `listTables`, `createTable` and `getTable` all return. `fields` is `[]` for a table with no fields, which is exactly what a freshly created table is.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Table"
          },
          {
            "type": "object",
            "required": [
              "fields"
            ],
            "properties": {
              "fields": {
                "type": "array",
                "description": "Every field of the table, ordered by `position` then `created_at`. Identical objects to the ones the dedicated fields endpoint returns, `operators` included.",
                "items": {
                  "$ref": "#/components/schemas/Field"
                }
              }
            }
          }
        ]
      },
      "DeleteResult": {
        "type": "object",
        "title": "DeleteResult",
        "description": "What a structural delete returns. Nothing else is reported — not what cascaded, not how many rows went.",
        "required": [
          "id",
          "deleted"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The id you asked to delete, echoed back."
          },
          "deleted": {
            "type": "boolean",
            "description": "Always literally `true`; there is no `false` case, a failure is an error status instead.",
            "const": true
          }
        },
        "additionalProperties": false
      },
      "StoredField": {
        "type": "object",
        "title": "StoredField",
        "description": "The stored `catalog.fields` row exactly as PATCH /api/fields/{id} returns it — identical to `Field` except that it carries NO `operators` property (only the list and create responses add that). Every other key, `physical_name` included, is the same.",
        "required": [
          "id",
          "table_id",
          "name",
          "storage_kind",
          "costume",
          "options",
          "physical_name",
          "position",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "fld_3n7pqrs2tuvw"
            ]
          },
          "table_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "storage_kind": {
            "type": "string",
            "enum": [
              "text",
              "long_text",
              "number",
              "date",
              "checkbox",
              "single_select",
              "multi_select",
              "link",
              "attachment"
            ]
          },
          "costume": {
            "type": "string"
          },
          "options": {
            "type": "object",
            "additionalProperties": true
          },
          "physical_name": {
            "type": "string"
          },
          "position": {
            "type": "integer"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "FieldType": {
        "type": "string",
        "title": "FieldType",
        "description": "The value `type` takes when creating a field — the costume, i.e. the entry in the field-type menu. The first 28 values are the real stored costumes (engine/costumes.ts); the last three are one-word doors accepted ONLY on create, where the concrete costume is inferred server-side.\n\nWhat each one stores, and what its own `options` keys are (any key from the whole known set is accepted on any costume — the pouch is one flat namespace, deliberately not per-costume — so this lists what each costume actually READS):\n\n• `single_line_text` — text column. `unique`.\n• `long_text` — text column. `rich` (markdown rendering).\n• `email`, `phone`, `url` — text column, no server-side format validation whatsoever. `unique` (email/phone/url).\n• `number` — numeric column. `decimals`, `precision`, `separators`, `unique`.\n• `currency` — numeric. `symbol`, `symbol_position`, `decimals`, `separators`, `unique`.\n• `percent` — numeric. `decimals`, `separators`, `unique`.\n• `rating` — numeric. `max`, `shape`, `color`.\n• `duration` — numeric holding SECONDS; written as `\"1:30\"` or as a number, always read back as seconds. `duration_format`.\n• `date` — `date` column, or `timestamptz` when `include_time` is true. `include_time` (FROZEN after creation), `date_format`, `time_format`, `unique`.\n• `checkbox` — boolean. `icon`, `checked_color`, `unchecked_color`.\n• `member` — text column holding a member's email. `picker_field_ids` is not used here; there are no `choices`.\n• `multi_member` — `text[]` (multi_select storage) holding member emails.\n• `single_select` — text column holding a choice ID. `choices`, `unique`.\n• `multi_select` — `text[]` of choice IDs. `choices`.\n• `link` — NO column: a connector table shared with the mirror field. `target_table_id` (required, same database), `allow_multiple`, `reverse_field_name` (create only), `conditions` (a §9 `Filter` over the target table constraining what the picker offers), `picker_field_ids` (≤ 4 fields, or ≤ 3 plus one attachment; never a button, never the target's display field). `direction` and `pair_field_id` are written by the server.\n• `attachment` — `text[]` of `att_…` ids.\n• `button` — NO column and no per-record value at all (its cell always reads null and cannot be written). `label` (truncated to 40 chars, default \"Button\"), `color`, `action` (`\"webhook\"` or `\"link\"`, inferred from `webhook_id` when absent), `webhook_id` (must be a webhook in this database), `url_template` (≤ 2000 chars, must resolve to http:// or https:// once `{Field}` tokens are blanked), `condition` (a §9 `Filter`; a non-matching record's button is disabled and a crafted press is refused).\n• `formula_number` / `formula_text` / `formula_date` — a real `GENERATED ALWAYS AS (…) STORED` column. `expression` (fields referenced as `{Field Name}`); the server stores the canonicalised text plus `referenced_field_ids`. Volatile expressions (anything clock-based) are impossible: Postgres forbids them in a generated column.\n• `lookup_text` / `lookup_number` / `lookup_date` / `lookup_checkbox` — NO column; computed read-time across a link. `link_field_id` (a link field on THIS table), `target_field_id` (a field on the linked table, itself not a lookup/rollup), optional `conditions` (a §9 `Filter` over the target table). The server also stores `source_presentation`, a snapshot of the surfaced field's costume+options, so a currency lookup still reads as currency. Which concrete costume you get is decided by the surfaced field's storage kind: text/long_text/single_select/multi_select/link/attachment → `lookup_text`, number → `lookup_number`, date → `lookup_date`, checkbox → `lookup_checkbox`.\n• `rollup_number` / `rollup_date` — NO column; aggregated read-time. `link_field_id`, `aggregation` (`sum`|`count`|`min`|`max`|`avg`), `target_field_id` (required except for `count`, which never reads one — a `target_field_id` sent alongside `count` is accepted, ignored, and stored back as null), optional `conditions`, plus `decimals`/`separators` overriding the inherited format. `sum`/`avg` need a number field; `min`/`max` need a number or date field and keep its kind — which is the only way to get `rollup_date`.\n• `formula` / `lookup` / `rollup` — the one-word doors. Accepted by POST only; the stored costume is always one of the concrete names above. They are NOT listed in the \"Unknown field type\" error, and they are NOT accepted by PATCH.",
        "enum": [
          "single_line_text",
          "long_text",
          "email",
          "phone",
          "url",
          "number",
          "currency",
          "percent",
          "rating",
          "duration",
          "date",
          "checkbox",
          "member",
          "multi_member",
          "single_select",
          "multi_select",
          "link",
          "attachment",
          "button",
          "formula_number",
          "formula_text",
          "formula_date",
          "lookup_text",
          "lookup_number",
          "lookup_date",
          "lookup_checkbox",
          "rollup_number",
          "rollup_date",
          "formula",
          "lookup",
          "rollup"
        ]
      },
      "FieldOptions": {
        "type": "object",
        "title": "FieldOptions",
        "description": "The settings pouch. ONE flat known-key set for every costume, not a per-costume shape: a pouch outlives the costume that wrote it, so a text field converted from a currency field legitimately still carries `symbol`. A write may only INTRODUCE a key from this list; a key already stored on the field is tolerated forever. An unknown new key is refused with 400 naming it and printing the whole valid list — on field CREATION for every costume, and on PATCH only for a plain field, a select, or any `type` change. A PATCH of a formula's, lookup/rollup's, button's or link's `options` bypasses that check entirely. See `FieldType` for which costume reads which key.",
        "properties": {
          "rich": {
            "type": "boolean",
            "description": "long_text: render as markdown."
          },
          "unique": {
            "type": "boolean",
            "description": "Ask for a real partial UNIQUE index (`WHERE deleted_at IS NULL`) on the field's column. The index is built ONLY at creation, and only for single_line_text, email, phone, url, number, currency, percent, date, single_select; sent on any other costume the flag is stored in the pouch and no index exists, so it enforces nothing. Create-only: an `options` edit can neither add nor remove it — the stored flag is carried forward on those nine costumes and stripped from the pouch on any other. One consequence worth knowing: nothing ever DROPS the index short of deleting the field, so a `type` change carries a live unique index onto a costume that could not have asked for one (a unique `number` retyped to `rating` still refuses duplicates), and a later `options` edit will then quietly strip the `unique` flag from the pouch while the index goes on enforcing."
          },
          "decimals": {
            "type": "integer"
          },
          "precision": {
            "type": "integer"
          },
          "separators": {
            "type": "boolean"
          },
          "symbol": {
            "type": "string"
          },
          "symbol_position": {
            "type": "string"
          },
          "max": {
            "type": "integer",
            "description": "rating: how many marks."
          },
          "shape": {
            "type": "string",
            "description": "rating: which glyph."
          },
          "color": {
            "type": "string",
            "description": "rating/button colour."
          },
          "duration_format": {
            "type": "string"
          },
          "include_time": {
            "type": "boolean",
            "description": "date: store an instant (timestamptz) rather than a calendar day. FROZEN after creation — a PATCH that changes it is refused, because it fixed the real column type."
          },
          "date_format": {
            "type": "string"
          },
          "time_format": {
            "type": "string"
          },
          "icon": {
            "type": "string",
            "description": "checkbox glyph."
          },
          "checked_color": {
            "type": "string"
          },
          "unchecked_color": {
            "type": "string"
          },
          "choices": {
            "type": "array",
            "description": "Select costumes. Normalised to `{id, name, color}`; an entry sent without an `id` is minted one, an entry with an `id` keeps it — so a rename touches zero records. Names are trimmed, capped at 500 characters, at most 1000 per field, may not be `__tabla_no_such_choice__` (a reserved sentinel), and a NEW id-less entry may not duplicate a name already seen (a pre-existing duplicate that carries its own id is tolerated, so legacy fields stay editable). On a PATCH that omits this key entirely, the stored choices are preserved; an explicit `[]` clears them.",
            "items": {
              "type": "object",
              "required": [
                "name"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "description": "8 characters, no prefix. Omit to mint a new one."
                },
                "name": {
                  "type": "string"
                },
                "color": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              },
              "additionalProperties": true
            }
          },
          "allow_multiple": {
            "type": "boolean",
            "description": "link: may the cell hold more than one record. Defaults to false. Locked after creation. The auto-created mirror is always multiple."
          },
          "picker_field_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "link: fields of the LINKED table shown on the picker's record cards. Up to four labelled fields, or three plus one attachment (which becomes the card thumbnail). Duplicates are collapsed. A button, or the linked table's own display field, is refused by name. Per side: each direction's field row names fields from the table ITS picker lists."
          },
          "target_table_id": {
            "type": "string",
            "description": "link: required at creation; must be a table in the SAME database that the caller can edit. Locked afterwards."
          },
          "direction": {
            "type": "string",
            "description": "link: written by the server — `\"forward\"` on the side you created, `\"reverse\"` on the mirror. Locked."
          },
          "pair_field_id": {
            "type": "string",
            "description": "link: written by the server — the other side's field id. Locked."
          },
          "reverse_field_name": {
            "type": "string",
            "description": "link, create only: the mirror field's name on the target table. Defaults to the SOURCE TABLE's own name. A collision there is a 409."
          },
          "expression": {
            "type": "string",
            "description": "formula: the source text, fields referenced as `{Field Name}`. Stored back canonicalised (bare references get braces) so a later rename can rewrite it. Compiled to SQL and proven by Postgres before the field exists."
          },
          "referenced_field_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "formula: written by the server — the field ids the expression compiled against."
          },
          "link_field_id": {
            "type": "string",
            "description": "lookup/rollup: the link field on THIS table to travel across."
          },
          "target_field_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "lookup/rollup: the field on the linked table to surface or aggregate. Required for a lookup and for every aggregation except `count`, which stores null."
          },
          "aggregation": {
            "type": "string",
            "enum": [
              "sum",
              "count",
              "min",
              "max",
              "avg"
            ],
            "description": "rollup only."
          },
          "conditions": {
            "$ref": "#/components/schemas/Filter",
            "description": "lookup/rollup: only linked rows matching this §9 filter feed the value. link: only matching records may be picked. Compiled against the TARGET table at write time, so a bad field reference or operator is refused by name here rather than discovered on a read."
          },
          "source_presentation": {
            "type": "object",
            "additionalProperties": true,
            "description": "lookup/rollup: written by the server — a `{costume, options}` snapshot of the surfaced field, so the borrowed value keeps its currency symbol / date format. A plain `count` has no source and keeps none."
          },
          "label": {
            "type": "string",
            "description": "button: truncated to 40 characters; empty or absent becomes \"Button\"."
          },
          "action": {
            "type": "string",
            "enum": [
              "webhook",
              "link"
            ],
            "description": "button: inferred when absent — `webhook` if a non-empty `webhook_id` is present, otherwise `link`."
          },
          "url_template": {
            "type": "string",
            "description": "button, `link` action: up to 2000 characters. `{Field Name}` tokens are filled from the record and URL-encoded at press time. Must begin http:// or https:// once its tokens are blanked, so `javascript:`/`data:` cannot slip in."
          },
          "webhook_id": {
            "type": "string",
            "description": "button, `webhook` action: a webhook in THIS database. One in another database reads as 404."
          },
          "condition": {
            "$ref": "#/components/schemas/Filter",
            "description": "button: the press is only allowed for records matching this §9 filter — re-checked server-side, so a disabled button cannot be fired by a crafted call."
          }
        },
        "additionalProperties": true
      },
      "FieldCreate": {
        "type": "object",
        "title": "FieldCreate",
        "required": [
          "name",
          "type"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Required, must be a non-empty string (whitespace-only is refused). Trimmed before storing. Must be unique within the table — a duplicate is 409."
          },
          "type": {
            "$ref": "#/components/schemas/FieldType"
          },
          "options": {
            "$ref": "#/components/schemas/FieldOptions",
            "description": "Optional. Absent or null → `{}`. Anything that is not a JSON object is refused."
          }
        },
        "additionalProperties": true
      },
      "FieldUpdate": {
        "type": "object",
        "title": "FieldUpdate",
        "description": "At least one of `name`, `type`, `options` must be present. Sending `\"options\": null` counts as present and empties the pouch.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "New label. `null` is treated as \"not sent\". NOT checked for emptiness and NOT trimmed here (unlike create) — only for uniqueness within the table."
          },
          "type": {
            "type": [
              "string",
              "null"
            ],
            "description": "New costume. `null` is treated as \"not sent\". Must be a concrete costume — the one-word doors `formula`/`lookup`/`rollup` are not accepted. A value equal to the current costume is treated exactly as if `type` had been omitted (no conversion is attempted; any `name`/`options` in the same call still apply). See the operation description for the exact allowed conversions."
          },
          "options": {
            "$ref": "#/components/schemas/FieldOptions",
            "description": "Replaces the whole pouch, with the documented exceptions (select `choices` preserved when omitted; `unique` and link wiring carried forward; formula/lookup/rollup/button re-validated as complete configs)."
          }
        },
        "additionalProperties": true
      },
      "View": {
        "type": "object",
        "title": "View",
        "description": "A saved view — the whole `catalog.views` row. A view is remembered view-bar state, not a permission boundary and not data: hiding a field in a view does not hide it from the records API. Every table always has at least one view, and the last one cannot be deleted.",
        "required": [
          "id",
          "table_id",
          "name",
          "position",
          "config",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`viw_` + 12 characters.",
            "examples": [
              "viw_2p6rstu4vwxy"
            ]
          },
          "table_id": {
            "type": "string",
            "description": "`tbl_` + 12 characters."
          },
          "name": {
            "type": "string",
            "description": "Not unique within a table, and not guaranteed non-empty (PATCH accepts an empty string)."
          },
          "position": {
            "type": "integer",
            "description": "Order within the table, starting at 0. A new view takes the current maximum + 1; deleting a view does not renumber the rest."
          },
          "config": {
            "$ref": "#/components/schemas/ViewConfig"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "ViewConfig": {
        "type": "object",
        "title": "ViewConfig",
        "description": "A free-form settings pouch, stored verbatim. The server imposes NO schema on it: unknown keys are kept, a filter naming a deleted field is kept, and only relative-date objects nested anywhere inside are validated (and only on PATCH, not on create). `{}` is a perfectly valid config and is what a fresh view gets.\n\nThe keys below are the ones Tabla's own workspace writes and reads — a convention of the product, not a contract enforced by the API. Two of them are shaped differently from what the records endpoints take, which is the trap worth knowing:\n\n• `filters` — `{join: \"and\"|\"or\", conditions: [{fieldId, op, value}]}`. Note **`fieldId`**, NOT the `field` key the §9 `Filter` uses; the client converts it (dropping rows whose field is gone, and value-carrying rows with an empty value) before sending it to the records endpoint. It is not the same object.\n• `sorts` — `[{fieldId, dir: \"asc\"|\"desc\"}]`, serialised to the records endpoint's `field:dir,field:dir` string.\n• `hidden_fields` — field ids hidden in this view. The public share read is the one place this is load-bearing.\n• `field_order` — field ids in display order; `col_widths` — `{fieldId: pixels}`.\n• `row_height` — a name (`\"short\"` is the fallback), not a number.\n• `color_rules` — the conditional row-colouring rules; `summaries` — the per-column footer aggregate.\n• `type` — `\"calendar\"`, `\"gallery\"` or `\"kanban\"`; anything else (including absent) means a grid. Only this key decides which surface renders.\n• Per-type keys: `calendar_field`, `calendar_end_field_id`, `calendar_range`, `calendar_custom_n`, `calendar_custom_unit`, `calendar_show_weekends`, `calendar_cover_field_id`; `gallery_card_size`, `gallery_cover_aspect`, `gallery_show_labels`; `kanban_cover_mode`, `kanban_cover_aspect`, `kanban_show_labels`, `kanban_show_uncategorized`; `group_field_id`, `group_unit`, `group_sort`, `group_collapsed`, `group_color_rules`, `group_cover_field_id`.",
        "additionalProperties": true
      },
      "FieldKeyMap": {
        "title": "FieldKeyMap",
        "description": "The companion vocabulary map every RECORD-READ response carries (the listers and the single-record read). Exactly one of the two keys is present, decided by `key_by`, and it is built from the VISIBLE field set — so a `fields=` projection's map describes precisely what the payload contains and nothing more. Write responses (create/update/upsert/delete) do NOT carry it.",
        "oneOf": [
          {
            "type": "object",
            "required": [
              "field_ids"
            ],
            "properties": {
              "field_ids": {
                "type": "object",
                "description": "Present when cells are keyed by NAME (the default): field name → field id.",
                "additionalProperties": {
                  "type": "string"
                },
                "examples": [
                  {
                    "Name": "fld_3n7pqrs2tuvw",
                    "Status": "fld_8k1lmno9pqrs"
                  }
                ]
              }
            }
          },
          {
            "type": "object",
            "required": [
              "field_names"
            ],
            "properties": {
              "field_names": {
                "type": "object",
                "description": "Present when `key_by=id`: field id → field name.",
                "additionalProperties": {
                  "type": "string"
                },
                "examples": [
                  {
                    "fld_3n7pqrs2tuvw": "Name",
                    "fld_8k1lmno9pqrs": "Status"
                  }
                ]
              }
            }
          }
        ]
      },
      "RecordPage": {
        "type": "object",
        "title": "RecordPage",
        "description": "One page from either lister. `bookmark` is present ONLY when another page exists — its absence is the end-of-results signal, so page until it stops coming rather than until `records` is empty.",
        "required": [
          "records"
        ],
        "properties": {
          "records": {
            "type": "array",
            "description": "Up to `page_size` records, in the requested sort order (record id ascending when no sort was given).",
            "items": {
              "$ref": "#/components/schemas/Record"
            }
          },
          "bookmark": {
            "type": "string",
            "description": "Opaque base64url cursor for the next page. Pass it back as `bookmark` WITH the same `sort` — a mismatch in the number of sort terms is refused with 400."
          }
        },
        "allOf": [
          {
            "$ref": "#/components/schemas/FieldKeyMap"
          }
        ]
      },
      "RecordBatch": {
        "type": "object",
        "title": "RecordBatch",
        "description": "What a create or update returns: the affected records read back inside the same transaction, in the order they were sent. Cells are ALWAYS keyed by field name here — `key_by` is a read-only parameter — and there is no field map.",
        "required": [
          "records"
        ],
        "properties": {
          "records": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Record"
            }
          }
        },
        "additionalProperties": false
      },
      "UpsertResult": {
        "type": "object",
        "title": "UpsertResult",
        "description": "What a PUT upsert returns. `records` holds every record in the order sent (created and updated interleaved as they came in); the two id lists say which is which, in the order each was written.",
        "required": [
          "records",
          "created_records",
          "updated_records"
        ],
        "properties": {
          "records": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Record"
            }
          },
          "created_records": {
            "type": "array",
            "description": "Ids of the records that did not match and were created. Empty when everything matched.",
            "items": {
              "type": "string",
              "examples": [
                "rec_7k2mnpq4rstv"
              ]
            }
          },
          "updated_records": {
            "type": "array",
            "description": "Ids of the records that matched an existing row and were updated.",
            "items": {
              "type": "string"
            }
          }
        },
        "additionalProperties": false
      },
      "RecordWrite": {
        "type": "object",
        "title": "RecordWrite",
        "description": "One record body on a create or an upsert. The only key read is `fields`; anything else is ignored (an `id` sent here is NOT honoured — a create always mints a fresh `rec_…`, and an upsert always matches on `merge_on`).",
        "properties": {
          "fields": {
            "type": [
              "object",
              "null"
            ],
            "description": "Cell values keyed by field id OR field name (mixing both is fine, but naming the SAME field twice is 400). Omitted or null creates a blank record.\n\nWhat each kind accepts on the way in:\n• text/long_text/email/phone/url/member — a string. There is no server-side format check on email/phone/url; a member cell takes the member's email as a plain string.\n• number/currency/percent — a finite number (a numeric STRING is refused). `rating` is rounded and clamped to the field's `max`, and ≤ 0 becomes null. `duration` takes `\"1:30\"`, `\"1:30:45\"`, a number of seconds, or a numeric STRING of seconds (the one number costume that accepts a string), refuses a negative, and always reads back as seconds.\n• checkbox — true or false. Anything else is refused.\n• date — a date string; a shaped-but-impossible date (e.g. 2026-02-30) is 400, and an include_time field refuses a partial timestamp.\n• single_select — a choice id or a choice name. multi_select — a list of them. multi_member — a list of emails (non-string entries are silently dropped, not refused).\n• link — a list of `rec_…` ids, or the `{id, …}` objects a read returns. De-duplicated. Every id must be a live record in the target table.\n• attachment — a list of `att_…` ids that were uploaded into THIS record and THIS field. Never settable on a create.\n• formula / lookup / rollup / button — refused with 400; they are computed or valueless.\n\nnull clears a cell; `[]` clears a link or attachment cell.",
            "additionalProperties": true
          }
        },
        "additionalProperties": true
      },
      "RecordUpdate": {
        "type": "object",
        "title": "RecordUpdate",
        "description": "One record body on a PATCH: the same `fields` object as RecordWrite, plus the record's id and the optional concurrency token.",
        "required": [
          "id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The record to update (`rec_…`). Required; a missing or non-`rec_` value is 400. The same id twice in one batch is 400.",
            "examples": [
              "rec_7k2mnpq4rstv"
            ]
          },
          "if_updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Optional per record. The `updated_at` string you last read for this record, sent back verbatim. If it no longer matches, the WHOLE batch is refused with 409 before anything is written and the 409's `records` carry the current state. Omit for last-write-wins. A non-string value is 400.\n\nBeware it also advances when a link write elsewhere changes this record's link cell (a counterpart, or a record a single-link steal took a link from)."
          },
          "fields": {
            "type": [
              "object",
              "null"
            ],
            "description": "Only the fields present are written; anything left out is untouched. See RecordWrite for the per-kind accepted shapes. Attachment ids dropped out of a cell are permanently purged after commit when the new value is a LIST (`[]` included); sending `null` empties the cell without purging anything.",
            "additionalProperties": true
          }
        },
        "additionalProperties": true
      },
      "ButtonPressResult": {
        "title": "ButtonPressResult",
        "description": "The outcome of a press — which of the two shapes you get is decided by the button field's own configured action, not by anything in the request.",
        "oneOf": [
          {
            "type": "object",
            "title": "ButtonPressLink",
            "required": [
              "action",
              "url"
            ],
            "properties": {
              "action": {
                "type": "string",
                "const": "link"
              },
              "url": {
                "type": "string",
                "description": "The button's URL template with every `{Field Name}` token replaced by this record's own value, URL-encoded. It is returned for the caller to open; nothing was fetched or followed server-side.",
                "examples": [
                  "https://example.com/invoice?guest=Layla%20Test"
                ]
              }
            },
            "additionalProperties": false
          },
          {
            "type": "object",
            "title": "ButtonPressWebhook",
            "required": [
              "action",
              "delivered",
              "webhook_id"
            ],
            "properties": {
              "action": {
                "type": "string",
                "const": "webhook"
              },
              "delivered": {
                "type": "boolean",
                "const": true,
                "description": "Always true, and it means ENQUEUED, not \"the endpoint answered\". The delivery is written to the outbox and picked up by the same worker that handles every other delivery — its success or failure is visible in the webhook's delivery ledger, not here."
              },
              "webhook_id": {
                "type": "string",
                "description": "The webhook the button borrowed (`whk_…`).",
                "examples": [
                  "whk_2p6qrstu8vwx"
                ]
              }
            },
            "additionalProperties": false
          }
        ]
      },
      "TrashList": {
        "type": "object",
        "title": "TrashList",
        "description": "The trash listing. Two keys at most — there is no total count and no field-name map.",
        "required": [
          "records"
        ],
        "properties": {
          "records": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TrashedRecord"
            }
          },
          "bookmark": {
            "type": "string",
            "description": "Pass back as `bookmark` for the next page. Present only when there IS a next page; an absent bookmark means this was the last one."
          }
        },
        "additionalProperties": false
      },
      "TrashedRecord": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Record"
          },
          {
            "type": "object",
            "properties": {
              "deleted_at": {
                "type": "string",
                "format": "date-time",
                "description": "ISO 8601 UTC instant the record was trashed. The retention clock runs from here; it is also the primary sort key of this listing."
              }
            },
            "required": [
              "deleted_at"
            ]
          }
        ],
        "description": "A record in the trash: exactly the four keys of `Record` (see #/components/schemas/Record for how each cell kind serialises) plus `deleted_at`. Note `updated_at` is the record's own last-edit stamp — trashing does not touch it, so it can be older than `deleted_at`."
      },
      "TrashIdsRequest": {
        "type": "object",
        "title": "TrashIdsRequest",
        "description": "The body of a restore. The handler reads only `ids`; anything else in the object is ignored.",
        "required": [
          "ids"
        ],
        "properties": {
          "ids": {
            "type": "array",
            "description": "Between 1 and 100 `rec_…` ids, all in this table's trash. Duplicates are de-duplicated before the batch runs (so the same id twice is not an error, and the response is shorter than the request). The 100 cap is measured on the RAW list, before that de-duplication — 101 entries collapsing to three distinct ids are still refused. Every entry must be a string starting `rec_`; the prefix is all that is checked, so a well-formed id naming nothing reaches the trash lookup and comes back 404.",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "type": "string",
              "examples": [
                "rec_7k2mnpq4rstv"
              ]
            }
          }
        },
        "additionalProperties": true
      },
      "PurgeRequest": {
        "type": "object",
        "title": "PurgeRequest",
        "description": "The body of a purge. Send `ids` OR `\"all\": true` — `all` is tested first, so a body carrying both empties the whole trash and ignores `ids` entirely.",
        "properties": {
          "ids": {
            "type": "array",
            "description": "Between 1 and 100 `rec_…` ids, all already in this table's trash. De-duplicated before the batch runs, but the 100 cap is measured on the raw list beforehand. `\"ids\": null` is not the same as omitting it: null still routes here and is refused as \"not a list\".",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "type": "string",
              "examples": [
                "rec_7k2mnpq4rstv"
              ]
            }
          },
          "all": {
            "type": "boolean",
            "description": "Exactly `true` empties the trash (scoped to your own records unless you are an owner). Any other value is not treated as an instruction: the request then falls through to `ids`, and 400s if `ids` is absent."
          }
        },
        "additionalProperties": true
      },
      "PurgeResult": {
        "type": "object",
        "title": "PurgeResult",
        "description": "The answer to an id-list purge: one acknowledgement per record actually deleted, in the order Postgres returned them. `deleted` is always literally `true` — it is an acknowledgement, not a per-record outcome, because a partial purge cannot happen.",
        "required": [
          "records"
        ],
        "properties": {
          "records": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "deleted"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "examples": [
                    "rec_7k2mnpq4rstv"
                  ]
                },
                "deleted": {
                  "type": "boolean",
                  "enum": [
                    true
                  ]
                }
              },
              "additionalProperties": false
            }
          }
        },
        "additionalProperties": false
      },
      "EmptyTrashResult": {
        "type": "object",
        "title": "EmptyTrashResult",
        "description": "The answer to `\"all\": true` — how many records were purged in total across every 100-row batch. Zero when the trash was already empty (or held nothing this caller was allowed to purge); never an error.",
        "required": [
          "deleted"
        ],
        "properties": {
          "deleted": {
            "type": "integer",
            "minimum": 0
          }
        },
        "additionalProperties": false
      },
      "Webhook": {
        "type": "object",
        "title": "Webhook",
        "description": "One outbound webhook: the `catalog.webhooks` row handed out unfiltered, except that `secret` is blanked for viewers. There is no `updated_at` — the table does not have one.",
        "required": [
          "id",
          "database_id",
          "table_id",
          "name",
          "url",
          "trigger_type",
          "conditions",
          "watched_field_ids",
          "payload_key",
          "secret",
          "is_active",
          "consecutive_failures",
          "paused_at",
          "watch_seeded_at",
          "last_status",
          "last_attempt_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`whk_` + 12 characters.",
            "examples": [
              "whk_6p2qrstu4vwx"
            ]
          },
          "database_id": {
            "type": "string",
            "description": "`db_` + 12 characters. A webhook always belongs to a database, even when it is scoped to one table."
          },
          "table_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "`tbl_` + 12 characters, or null. NULL MEANS EVERY TABLE in the database — including tables created after the webhook was. A whole-database webhook cannot carry `conditions` or `watched_field_ids`, because neither has a field set to resolve against.\n\nWriting it: an EMPTY STRING is not a way to say null. `\"\"` skips the \"is this table in this database\" check (which only runs on a truthy value) and is then written straight into a column with a foreign key to `catalog.tables`, so the request dies on that constraint instead of returning a clean 400 or 404. Send `null`, or omit the key."
          },
          "name": {
            "type": "string",
            "description": "A label for humans; it is never sent to the receiver. Trimmed on write, and a blank or omitted name becomes exactly `Untitled webhook` — it is never stored empty."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The endpoint every delivery is POSTed to. See `createWebhook` for what the address guard permits."
          },
          "trigger_type": {
            "type": "string",
            "description": "Exactly ONE trigger per webhook — not a set of flags. What each one means:\n\n• `record_created` — a record was created and (if `conditions` are set) matches them now. RESTORING a record from the trash also enqueues this event (a restore is treated as the record coming back into existence, `before` null), so a receiver may see `record.created` twice for the same `record_id`.\n• `record_updated` — a record was updated, at least one WATCHED field changed, and the record matches `conditions` in its AFTER state. Its payload event name is `record.updated`.\n• `record_matches` — TRANSITION semantics, and the only trigger that can fire without a write. It fires when the record did NOT match before and DOES match after, so re-saving an already-matching record fires nothing; creation counts as a transition, since \"before\" is definitionally non-matching. `watched_field_ids` is ignored for this trigger. Its payload event name is `record.matches` whether the underlying change was a create or an update, and it never fires on delete. If its conditions name a moment (a relative date value such as `{\"relative\":\"now\"}`), it is ALSO evaluated on a clock roughly every 30 seconds, so a record can start matching purely because time passed; the two paths share one memory of what already matches so nothing fires twice.\n• `record_deleted` — a record was deleted, evaluated against its FINAL (before) state. Note this fires on the record entering the trash, and on nothing else — purging a trashed record for good enqueues no delivery at all.\n\nA delivery is only enqueued for an ACTIVE, un-paused webhook, in the same transaction as the record write itself — if the write rolls back, the delivery never existed.",
            "enum": [
              "record_created",
              "record_updated",
              "record_matches",
              "record_deleted"
            ]
          },
          "conditions": {
            "description": "A §9 filter over the webhook's table, or null. Only records matching it fire. It is the SAME grammar the records list and saved views speak, and it is compiled against the table's real fields when you save it, so an unknown field or a wrong operator is refused up front rather than silently mis-firing later. Lookup/rollup fields are refused here (they are never filterable in this position). Evaluated in memory at write time, not in SQL — an include_time date's day boundary is resolved in the DATABASE's own timezone, exactly as the grid filter does, so a trigger and a filter with the same terms cannot disagree about which day a record falls on.\n\nRequires a `table_id`. Note `{}` counts as present, so sending an empty object on a whole-database webhook is a 400 — send nothing at all instead.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Filter"
              },
              {
                "type": "null"
              }
            ]
          },
          "watched_field_ids": {
            "type": [
              "array",
              "null"
            ],
            "description": "`fld_…` ids that gate a `record_updated` webhook: it only fires when at least one of them actually changed. Null or an EMPTY LIST both mean \"watch every field\". Other triggers ignore it entirely.\n\nEvery id must be a field on the webhook's own table, and computed fields are refused by name: a formula, lookup or rollup value is derived rather than written and a button holds no value, so none of them can ever appear among a write's changed fields — watching one would leave a webhook that silently never fires.",
            "items": {
              "type": "string"
            }
          },
          "payload_key": {
            "type": "string",
            "description": "How every delivery keys the record's cells. `name` (the default) keys `before.fields`/`after.fields` by field NAME and adds a `field_ids` map (name → id). `id` keys them by stable `fld_…` id and adds the inverse `field_names` map (id → name) instead. Pick `id` if you are binding a mapping once and need it to survive a field being renamed. It also governs button-press deliveries sent through this webhook, so one receiver never sees two shapes from one endpoint.",
            "enum": [
              "name",
              "id"
            ],
            "default": "name"
          },
          "secret": {
            "type": "string",
            "description": "48 hex characters (24 random bytes), the HMAC-SHA256 key every delivery is signed with. **Blanked to `\"\"` for viewers** on the two read routes (`listWebhooks`, `getWebhook`) and on the one PATCH shape that only re-reads the row (`{\"resume\": false}`) — whoever holds it can forge a validly-signed delivery to your own endpoint, so it is treated as a write-side credential. Editors and owners see it in full. The delivery worker signs from its own direct read, so the redaction never affects real signing.\n\nHow to verify a delivery: recompute `\"sha256=\" + HMAC_SHA256(secret, X-Tabla-Timestamp + \".\" + rawBody)` and compare with `X-Tabla-Signature`. The timestamp is fresh unix seconds per ATTEMPT, so a retry of the same delivery carries a different signature."
          },
          "is_active": {
            "type": "boolean",
            "description": "The manual on/off switch, independent of `paused_at`. While false, nothing is enqueued — and nothing is queued up for later either; events that happen while a webhook is off are simply dropped, never replayed when it comes back on. `sendWebhookTestDelivery` still works."
          },
          "consecutive_failures": {
            "type": "integer",
            "description": "Failed deliveries in a row, and the auto-pause counter. It moves only when a delivery EXHAUSTS all four of its attempts — an endpoint that is merely slow to recover within one delivery's own retries never counts against it. Any delivery that eventually succeeds resets it to 0. Test sends never touch it."
          },
          "paused_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the webhook AUTO-paused, or null. Set once `consecutive_failures` reaches 20 — Tabla's read of \"this endpoint is dead\". A paused webhook enqueues nothing. Clear it with `{\"resume\": true}`, which also zeroes `consecutive_failures` so it does not re-pause on the very next failure."
          },
          "watch_seeded_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Internal bookkeeping for the `record_matches` clock watcher: when it first recorded what already matched WITHOUT delivering any of it. Null until that first pass, and deliberately reset to null whenever the webhook's terms change or it is switched back on, which makes the watcher silently re-adopt the current matches instead of firing a backlog."
          },
          "last_status": {
            "type": [
              "string",
              "null"
            ],
            "description": "The outcome of the last health-affecting delivery: `delivered` or `failed`, or null before any. Test sends do not update it."
          },
          "last_attempt_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When that outcome was recorded."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "WebhookCreate": {
        "type": "object",
        "title": "WebhookCreate",
        "required": [
          "url",
          "trigger_type"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Required, non-empty. Validated by the address guard (DNS is resolved during the request)."
          },
          "trigger_type": {
            "type": "string",
            "enum": [
              "record_created",
              "record_updated",
              "record_matches",
              "record_deleted"
            ],
            "description": "Required. See `Webhook.trigger_type` for what each fires on."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional label. Blank, whitespace-only, null or absent all become `Untitled webhook`."
          },
          "table_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Scope to one table. Absent or null targets EVERY table in the database. Must be a table in this same database or the call is 404. An empty string is NOT read as null — see `Webhook.table_id`."
          },
          "conditions": {
            "description": "Optional §9 filter; requires `table_id`. Null is the same as absent.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Filter"
              },
              {
                "type": "null"
              }
            ]
          },
          "watched_field_ids": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Optional; requires `table_id` when non-empty. Only meaningful for `record_updated`."
          },
          "payload_key": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "name",
              "id",
              null,
              ""
            ],
            "description": "Defaults to `name`. Null, absent and the empty string all mean `name`; anything other than `name`/`id` is refused rather than guessed at, because the receiver is the one who pays for a wrong guess."
          }
        },
        "additionalProperties": true
      },
      "WebhookPatch": {
        "type": "object",
        "title": "WebhookPatch",
        "description": "Send at least one of these; unknown keys are ignored, and a body containing only unknown keys is a 400. Read the operation description before using `conditions`/`watched_field_ids` — null does not clear them.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "An empty string becomes `Untitled webhook`, it does not clear the name."
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Re-validated by the address guard."
          },
          "table_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The one key where an explicit null IS honoured: it re-scopes the webhook to the whole database. Re-validates the stored conditions and watched fields against the new scope. An empty string is NOT read as null — see `Webhook.table_id`."
          },
          "trigger_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "record_created",
              "record_updated",
              "record_matches",
              "record_deleted",
              null
            ]
          },
          "conditions": {
            "description": "Send `{}` to widen to every record; null is treated as absent and changes nothing.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Filter"
              },
              {
                "type": "null"
              }
            ]
          },
          "watched_field_ids": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Send `[]` to watch every field; null is treated as absent and changes nothing."
          },
          "payload_key": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "name",
              "id",
              null,
              ""
            ]
          },
          "is_active": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "The manual switch. Either value re-seeds a `record_matches` webhook's clock memory."
          },
          "resume": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Only `true` does anything: it clears `paused_at`, zeroes `consecutive_failures` and re-seeds the clock memory. `\"resume\": false` is accepted, satisfies the \"send at least one key\" rule, and does nothing at all — it is not a way to pause; use `\"is_active\": false` for that. Because it reaches no editor-gated function, a body whose only recognised key is `\"resume\": false` succeeds for a viewer as well."
          }
        },
        "additionalProperties": true
      },
      "WebhookDelivery": {
        "type": "object",
        "title": "WebhookDelivery",
        "description": "One row of the delivery ledger — one QUEUED NOTIFICATION, not one HTTP call. Retries reuse the same row, advancing `attempt_count`, `next_attempt_at` and the response columns each time, so a row that failed three times shows only the last attempt's response.",
        "required": [
          "id",
          "webhook_id",
          "event",
          "record_id",
          "payload",
          "attempt_count",
          "status",
          "next_attempt_at",
          "response_status",
          "response_body",
          "duration_ms",
          "last_error",
          "created_at",
          "delivered_at",
          "claimed_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`dlv_` + 12 characters. Also the first half of the `X-Tabla-Delivery` header the receiver sees, which is `<delivery id>.<attempt number>`.",
            "examples": [
              "dlv_8s3tuvwx5yza"
            ]
          },
          "webhook_id": {
            "type": "string",
            "description": "`whk_` + 12 characters."
          },
          "event": {
            "type": "string",
            "description": "The payload's event name — note it is dotted here, while the webhook's `trigger_type` is underscored. `record.matches` is what a `record_matches` webhook writes whether the underlying change was a create or an update. `webhook.test` is a probe from the test endpoint. `button.pressed` comes from a button field pointed at this webhook — an explicit human action that bypasses trigger-type and condition matching entirely, borrowing only this webhook's URL, secret, pause state and `payload_key`.",
            "enum": [
              "record.created",
              "record.updated",
              "record.matches",
              "record.deleted",
              "webhook.test",
              "button.pressed"
            ]
          },
          "record_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The record the event was about (`rec_…`), or null for a `webhook.test` delivery."
          },
          "payload": {
            "$ref": "#/components/schemas/WebhookPayload"
          },
          "attempt_count": {
            "type": "integer",
            "description": "HTTP attempts made so far, 0 until the first. The ceiling is 4 (the first try plus three retries)."
          },
          "status": {
            "type": "string",
            "description": "`pending` — queued, or waiting out a retry delay. `delivering` — claimed by the worker right now; a row stuck here for over 2 minutes is treated as a crashed attempt and reclaimed. `delivered` — a 2xx was received. `failed` — every attempt is spent, or it was a one-shot test that failed. There is no `cancelled`.",
            "enum": [
              "pending",
              "delivering",
              "delivered",
              "failed"
            ]
          },
          "next_attempt_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the worker may next pick this up. The retry ladder is fixed at 10 seconds, 60 seconds, then 5 minutes after the 1st, 2nd and 3rd failures. On a terminal failure this is set to the moment of that last attempt, not to a future time — do not read it as \"still scheduled\"; read `status`."
          },
          "response_status": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The endpoint's HTTP status on the most recent attempt; null when the attempt never got a response (DNS, TLS, connection refused, timeout). Success is 200–299 only — a 3xx counts as a failure, because redirects are deliberately not followed."
          },
          "response_body": {
            "type": [
              "string",
              "null"
            ],
            "description": "The endpoint's response body, TRUNCATED to 4000 characters (and never buffered past 100,000 bytes in the first place). Note the two write paths differ on emptiness: a SUCCESSFUL attempt stores an empty body as `\"\"`, while a FAILED one stores it as null."
          },
          "duration_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Wall-clock milliseconds of the most recent attempt, including a failed one. The request timeout is 10 seconds."
          },
          "last_error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the most recent attempt failed, as a sentence — a transport error's message, or the literal `HTTP <status>` when the endpoint answered with a non-2xx. Cleared to null once a delivery succeeds."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the delivery was ENQUEUED — for a record event this is inside the record write's own transaction, so it is the moment of the change."
          },
          "delivered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When a 2xx was received; null otherwise."
          },
          "claimed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Internal: when the worker last claimed this row. Used only to detect a claim abandoned by a crash."
          }
        },
        "additionalProperties": false
      },
      "WebhookPayload": {
        "type": "object",
        "title": "WebhookPayload",
        "description": "Exactly the JSON body POSTed to your endpoint — this is what the ledger stores, so reading a delivery shows you byte-for-byte what the receiver got (or will get). The keys present depend on the webhook's `payload_key`: `name` keying adds `field_ids`, `id` keying adds `field_names` instead. Never both.\n\nThe accompanying headers are set with lower-case names (HTTP header names are case-insensitive, but this is what goes on the wire): `content-type: application/json`, `x-tabla-delivery: <dlv id>.<attempt number>`, `x-tabla-timestamp: <unix seconds>` and `x-tabla-signature: sha256=<hmac>`. A `host` and a `content-length` are set too; nothing else is sent, and there is no `user-agent`.\n\nOne cap a receiver should know about: a single write touching 50 OR MORE records enqueues NOTHING — no per-record flood is sent for a bulk import or a mass delete.",
        "required": [
          "delivery_id",
          "event",
          "occurred_at",
          "database",
          "table",
          "record_id",
          "changed_field_ids",
          "before",
          "after",
          "test"
        ],
        "properties": {
          "delivery_id": {
            "type": "string",
            "description": "`dlv_…`. On a replay this is the NEW delivery's id, not the original's — everything else in the payload is the frozen original."
          },
          "event": {
            "type": "string",
            "enum": [
              "record.created",
              "record.updated",
              "record.matches",
              "record.deleted",
              "webhook.test",
              "button.pressed"
            ]
          },
          "trigger": {
            "type": "string",
            "description": "Present ONLY on a `button.pressed` payload, where it is the literal `\"button\"`.",
            "enum": [
              "button"
            ]
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the delivery was built, in UTC. Not re-stamped on a retry or a replay."
          },
          "database": {
            "type": [
              "object",
              "null"
            ],
            "description": "`{id, name}`. Null only on a test delivery whose database row could not be read.",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "table": {
            "type": [
              "object",
              "null"
            ],
            "description": "`{id, name}`, or null on a test delivery for a whole-database webhook. Real record events always carry the specific table, even when the webhook targets the whole database.",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "record_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "`rec_…`, or null on a test delivery."
          },
          "button": {
            "type": "object",
            "description": "Present only on `button.pressed`: `{field_id, label}`, where `label` is the button's configured label or, if it has none, the field's name.",
            "properties": {
              "field_id": {
                "type": "string"
              },
              "label": {
                "type": "string"
              }
            }
          },
          "pressed_by": {
            "type": "string",
            "description": "Present only on `button.pressed`: the pressing user's EMAIL ADDRESS. Falls back to their `usr_…` id if the account row can't be read, and to the literal `\"unknown\"` when no identity resolves at all (a press driven by a bare API key). Never null and never absent on a button payload."
          },
          "pressed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Present only on `button.pressed`."
          },
          "changed_field_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The `fld_…` ids that actually changed in this write. Always an EMPTY list on `webhook.test` and `button.pressed`, and it is what a `record_updated` webhook's `watched_field_ids` is checked against."
          },
          "field_ids": {
            "type": "object",
            "description": "Present when `payload_key` is `name` (the default): field NAME → `fld_…` id, for every field of the table, so a name-keyed receiver can still translate. Absent under `id` keying.",
            "additionalProperties": {
              "type": "string"
            }
          },
          "field_names": {
            "type": "object",
            "description": "Present when `payload_key` is `id`: `fld_…` id → field NAME. The exact inverse of `field_ids`; you never get both.",
            "additionalProperties": {
              "type": "string"
            }
          },
          "before": {
            "type": [
              "object",
              "null"
            ],
            "description": "The record's state BEFORE the change, as `{ \"fields\": { … } }` — note it is NOT a full Record: there is no id, created_at or updated_at inside it. Null on `record.created`, on a `record.matches` fired by a creation, on `webhook.test` and on `button.pressed`.",
            "required": [
              "fields"
            ],
            "properties": {
              "fields": {
                "type": "object",
                "description": "Cells keyed by name or id per `payload_key`, serialised exactly as the records API serialises them.",
                "additionalProperties": true
              }
            }
          },
          "after": {
            "type": [
              "object",
              "null"
            ],
            "description": "The record's state AFTER the change, same `{ \"fields\": { … } }` shape (again: no id, created_at or updated_at). Null on `record.deleted`. On `button.pressed` it is the record as it stands at the moment of the press — a press never mutates the record, so `before` is null and there is nothing to diff.",
            "required": [
              "fields"
            ],
            "properties": {
              "fields": {
                "type": "object",
                "additionalProperties": true
              }
            }
          },
          "test": {
            "type": "boolean",
            "description": "True only for a payload built by the test endpoint. Branch on this so a probe never runs your real automation."
          }
        },
        "additionalProperties": true
      },
      "WebhookTestResult": {
        "type": "object",
        "title": "WebhookTestResult",
        "description": "The raw outcome of the one test attempt. The camelCase `durationMs` is genuine, not a typo in this document.",
        "required": [
          "ok",
          "status",
          "body",
          "error",
          "durationMs"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True only when the endpoint answered 200–299. A 3xx is false — redirects are not followed."
          },
          "status": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The HTTP status the endpoint returned, or null when no response was ever received (DNS failure, connection refused, TLS error, the 10-second timeout, or the send-time address re-check refusing a host that now resolves internally). `0` if a response arrived carrying no status code at all."
          },
          "body": {
            "type": "string",
            "description": "The endpoint's response body truncated to 4000 characters. An empty string when there was none, including on a transport failure."
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "The transport failure's message when there was one, otherwise null — including when the endpoint answered with a non-2xx, in which case `error` is null and `status` tells the story."
          },
          "durationMs": {
            "type": "integer",
            "description": "Wall-clock milliseconds the attempt took."
          }
        },
        "additionalProperties": false
      },
      "TabularImportPreview": {
        "type": "object",
        "title": "TabularImportPreview",
        "description": "What `POST /api/databases/{id}/import` returns when only `file` is sent. Pure analysis of the uploaded bytes — nothing is created, and no database is even looked up.",
        "required": [
          "rowCount",
          "extraSheetsIgnored",
          "columns"
        ],
        "properties": {
          "rowCount": {
            "type": "integer",
            "description": "Data rows, excluding the header row. Capped at 50,000 — a larger file is refused here rather than at run time."
          },
          "extraSheetsIgnored": {
            "type": "integer",
            "description": "Worksheets beyond the first, which this importer does not read. Always 0 for a CSV."
          },
          "columns": {
            "type": "array",
            "description": "One entry per header cell, in file order. Headers are trimmed of surrounding whitespace; a blank header stays blank rather than being dropped, because a column's identity is its position.",
            "items": {
              "$ref": "#/components/schemas/TabularImportPreviewColumn"
            }
          }
        },
        "additionalProperties": false
      },
      "TabularImportPreviewColumn": {
        "type": "object",
        "title": "TabularImportPreviewColumn",
        "description": "One sniffed column. The guess is only a suggested default for building a plan — the run step never applies it on its own; it obeys the plan you send.",
        "required": [
          "columnIndex",
          "sourceHeader",
          "guessedCostume",
          "samples"
        ],
        "properties": {
          "columnIndex": {
            "type": "integer",
            "description": "0-based position. This is the column's identity everywhere; the header text is display only."
          },
          "sourceHeader": {
            "type": "string",
            "description": "The header cell, trimmed. May be an empty string, and may repeat across columns."
          },
          "guessedCostume": {
            "type": "string",
            "description": "First rule that matches all of up to 50 non-empty sample values wins, in this order: checkbox (true/false/1/0/yes/no) → number (with a currency upgrade when the header mentions price/cost/amount/total/fee/$ and every sample has exactly two decimals) → percent → date → email → url → multi_select (every sample delimited by comma-space or semicolon-space, ≤20 distinct tokens) → single_select (≤20 distinct values, average length ≤40, not all numeric) → long_text (a sample containing a newline or longer than 200 characters) → single_line_text. A value matching `^0\\d+$` anywhere (\"00123\") disables the checkbox, number and percent rules, so id-shaped columns keep their zeros. An all-empty column guesses single_line_text.\n\nNote the enum below is narrower than the importable-costume list: the sniffer can never produce `phone`, `rating` or `duration` — those exist only as costumes you choose yourself in a `create` action.",
            "enum": [
              "single_line_text",
              "long_text",
              "email",
              "url",
              "number",
              "currency",
              "percent",
              "date",
              "checkbox",
              "single_select",
              "multi_select"
            ]
          },
          "guessedOptions": {
            "type": "object",
            "description": "Present only for the guesses that carry settings: a date guess always brings `include_time` (true or false); a select guess brings seeded `choices` as `{name, color}` pairs (no ids yet — they are minted at creation) drawn from the twelve pigments in order. Absent otherwise.",
            "additionalProperties": true
          },
          "samples": {
            "type": "array",
            "description": "At most three sample values from this column — the raw, untrimmed strings, filtered only on being non-blank — for a human to eyeball the guess against.",
            "items": {
              "type": "string"
            },
            "maxItems": 3
          }
        },
        "additionalProperties": false
      },
      "TabularImportResult": {
        "type": "object",
        "title": "TabularImportResult",
        "description": "What the run branch returns. Deliberately narrow: it does not return the created records or their ids — read them back through the records list if you need them.",
        "required": [
          "tableId",
          "created"
        ],
        "properties": {
          "tableId": {
            "type": "string",
            "description": "The table the rows landed in — the one you named, or the one this run created (`tbl_` + 12 characters)."
          },
          "created": {
            "type": "integer",
            "description": "Rows written. On success this equals the file's data-row count, since every row is coerced before anything is written and columns with no plan entry still produce a record."
          }
        },
        "additionalProperties": false
      },
      "TabularImportTarget": {
        "title": "TabularImportTarget",
        "description": "Where the rows go — sent as a JSON string in the `target` multipart part. Two modes are understood. An unrecognised `mode` is NOT refused by name: it falls through to the new-table branch, so it either creates a table called whatever `name` holds or fails as a generic 500.",
        "oneOf": [
          {
            "type": "object",
            "title": "New table",
            "required": [
              "mode",
              "name"
            ],
            "properties": {
              "mode": {
                "type": "string",
                "const": "new_table"
              },
              "name": {
                "type": "string",
                "description": "The table to create. Trimmed; a blank name is refused with \"The new table needs a name.\" The table is created before any field or record, and is deleted again if the import then fails."
              }
            },
            "additionalProperties": true
          },
          {
            "type": "object",
            "title": "Existing table",
            "required": [
              "mode",
              "tableId"
            ],
            "properties": {
              "mode": {
                "type": "string",
                "const": "existing_table"
              },
              "tableId": {
                "type": "string",
                "description": "An existing table (`tbl_` + 12 characters). You must be editor on it AND it must belong to the database in the path — a table you own in a *different* database is refused as not found. Rows are appended; nothing already there is touched or deduplicated."
              }
            },
            "additionalProperties": true
          }
        ]
      },
      "TabularColumnPlan": {
        "type": "object",
        "title": "TabularColumnPlan",
        "description": "One column's decision — sent inside the JSON array in the `plan` multipart part. The array need not cover every column: an unlisted column is simply not imported. Note the run step re-parses the file, so `columnIndex` must still address the same file you previewed. Unknown extra keys are ignored, not rejected.",
        "required": [
          "columnIndex",
          "action"
        ],
        "properties": {
          "columnIndex": {
            "type": "integer",
            "description": "0-based position in the file, and the column's only identity. Must be within the file's header count or the request is refused naming the column 1-based. (A plan entry that omits it entirely escapes that bounds check and simply imports nothing for that column — send it.)"
          },
          "sourceHeader": {
            "type": "string",
            "description": "Carried for display and error messages only — never used to locate the column, never validated against the file, and its absence is not refused (it would just read as \"undefined\" inside any error message about this column)."
          },
          "action": {
            "$ref": "#/components/schemas/TabularFieldAction"
          }
        },
        "additionalProperties": true
      },
      "TabularFieldAction": {
        "title": "TabularFieldAction",
        "description": "What to do with a column: ignore it, write it into a field that already exists, or create a field for it. Unknown extra keys are ignored, not rejected.",
        "oneOf": [
          {
            "type": "object",
            "title": "Skip",
            "required": [
              "kind"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "const": "skip"
              }
            },
            "additionalProperties": true
          },
          {
            "type": "object",
            "title": "Map to an existing field",
            "required": [
              "kind",
              "targetFieldId"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "const": "map"
              },
              "targetFieldId": {
                "type": "string",
                "description": "A field already on the target table (`fld_` + 12 characters). Refused if it no longer exists, or if its costume is not one of the 14 importable ones (link, attachment and every formula/lookup/rollup costume are named and refused — they have no writable column). Mapping onto a select field auto-adds any choice values the column contains that the field does not have, appended after the existing choices with their ids and colours untouched; the merge is applied via a real field update before any record is written."
              }
            },
            "additionalProperties": true
          },
          {
            "type": "object",
            "title": "Create a field",
            "required": [
              "kind",
              "name",
              "costume"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "const": "create"
              },
              "name": {
                "type": "string",
                "description": "The new field's name, trimmed. Blank is refused, as is the same name twice in one plan, or a name a field on the target table already carries — all three checked before any field is minted. (The table-collision check reads a snapshot, so a field created concurrently after it can still surface as a 409 from the field creator itself.)"
              },
              "costume": {
                "type": "string",
                "description": "One of the 14 importable costumes. Anything else — including a costume that exists in the product, like `link` or `formula_number` — is refused naming it.",
                "enum": [
                  "single_line_text",
                  "long_text",
                  "email",
                  "phone",
                  "url",
                  "number",
                  "currency",
                  "percent",
                  "rating",
                  "duration",
                  "date",
                  "checkbox",
                  "single_select",
                  "multi_select"
                ]
              },
              "options": {
                "type": "object",
                "description": "The field's settings pouch, passed straight through to field creation (so the same known-key validation applies). For a select costume it is overwritten on the `choices` key: choices are recollected from EVERY row of the column, not the 50-row sniff sample, and each is seeded a pigment by position. Over 100 distinct choices is refused, telling you to import the column as text instead. `duration_format` and `include_time` here also drive how that column's cells are coerced.",
                "additionalProperties": true
              }
            },
            "additionalProperties": true
          }
        ]
      },
      "Health": {
        "type": "object",
        "title": "Health",
        "description": "The monitoring body. Not an `Error` envelope even when it reports failure, and never key-gated.",
        "required": [
          "ok",
          "name",
          "database"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "Always exactly the same value as `database` — there is no second condition folded into it."
          },
          "name": {
            "type": "string",
            "const": "tabla",
            "description": "A fixed literal identifying the product. It is not the deployment, the version, or the database's name."
          },
          "database": {
            "type": "boolean",
            "description": "True when `SELECT 1` against the catalog pool returned. Any thrown error — connection refused, timeout, auth failure — collapses to false; the reason is deliberately not reported."
          }
        },
        "additionalProperties": false
      },
      "Whoami": {
        "type": "object",
        "title": "Whoami",
        "description": "Who the credential belongs to. Three user columns only — no roles, no memberships, no database list.",
        "required": [
          "user",
          "key"
        ],
        "properties": {
          "user": {
            "type": "object",
            "required": [
              "id",
              "email",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "`usr_` + 12 characters.",
                "examples": [
                  "usr_7k2mnpq4rstv"
                ]
              },
              "email": {
                "type": "string",
                "description": "The account's address, stored trimmed and lower-cased. This is also the exact value a `member` cell holds."
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The display name, or null — null for any account that never supplied one, or that signed up before names were collected."
              }
            },
            "additionalProperties": false
          },
          "key": {
            "description": "The API key this request arrived on — populated whenever the bearer header is the credential the reported `user` was resolved from. It is null when a session cookie was ALSO sent and resolved to a different account than the key belongs to, since the lookup pins `user_id` to the reported account.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/ApiKeySummary"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "additionalProperties": false
      },
      "ApiKeySummary": {
        "type": "object",
        "title": "ApiKeySummary",
        "description": "A key's public half. The secret is never returned by any route — it exists only in the bearer value handed over once at creation.",
        "required": [
          "id",
          "name",
          "created_at",
          "last_used_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`key_` + 12 characters — the part before the dot in the bearer value, and safe to display.",
            "examples": [
              "key_3n7pqrs2tuvw"
            ]
          },
          "name": {
            "type": "string",
            "description": "The label chosen at creation. A key created with a blank name is stored as `Untitled key`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Stamped by the credential resolver on every successful call, best-effort (a failure to write it never fails the request). On this route it therefore reads as the current moment."
          }
        },
        "additionalProperties": false
      },
      "Member": {
        "type": "object",
        "title": "Member",
        "description": "One row of the access list: an account joined to its role in one database.",
        "required": [
          "user_id",
          "email",
          "role",
          "created_at"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "description": "`usr_` + 12 characters — the id to use in the member path routes.",
            "examples": [
              "usr_9q4rstuv2wxy"
            ]
          },
          "email": {
            "type": "string",
            "description": "The account's address."
          },
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the MEMBERSHIP was created, not the account. A later role change does not move it."
          }
        },
        "additionalProperties": false
      },
      "MemberRole": {
        "type": "string",
        "title": "MemberRole",
        "description": "Roles rank owner > editor > viewer. `owner` manages members and everything below; `editor` writes data; `viewer` reads. A role too low for an operation is 403 — being outside the database entirely is 404.",
        "enum": [
          "owner",
          "editor",
          "viewer"
        ]
      },
      "MemberList": {
        "type": "object",
        "title": "MemberList",
        "required": [
          "members",
          "my_role"
        ],
        "properties": {
          "members": {
            "type": "array",
            "description": "Ordered by membership creation date, ascending.",
            "items": {
              "$ref": "#/components/schemas/Member"
            }
          },
          "my_role": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MemberRole"
              }
            ],
            "description": "The caller's own role. It reads `viewer` for an operator holding a support-access grant, who does not appear in `members` at all."
          }
        },
        "additionalProperties": false
      },
      "MemberRoleRef": {
        "type": "object",
        "title": "MemberRoleRef",
        "description": "The deliberately narrower body a role change returns — no email, no `created_at`.",
        "required": [
          "user_id",
          "role"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "examples": [
              "usr_9q4rstuv2wxy"
            ]
          },
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          }
        },
        "additionalProperties": false
      },
      "MemberRemoved": {
        "type": "object",
        "title": "MemberRemoved",
        "required": [
          "user_id",
          "removed"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "examples": [
              "usr_9q4rstuv2wxy"
            ]
          },
          "removed": {
            "type": "boolean",
            "const": true,
            "description": "Always true on a 200 — there is no \"nothing to remove\" success; that case is a 404."
          }
        },
        "additionalProperties": false
      },
      "AddMemberBody": {
        "type": "object",
        "title": "AddMemberBody",
        "required": [
          "email",
          "role"
        ],
        "properties": {
          "email": {
            "type": "string",
            "description": "Must already belong to an account. Trimmed and lower-cased before lookup; validated only as `something@something.something`."
          },
          "role": {
            "allOf": [
              {
                "$ref": "#/components/schemas/MemberRole"
              }
            ],
            "description": "All three roles are allowed here, including `owner` — this is the one API path that can grant ownership."
          }
        },
        "additionalProperties": true
      },
      "ChangeRoleBody": {
        "type": "object",
        "title": "ChangeRoleBody",
        "required": [
          "role"
        ],
        "properties": {
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          }
        },
        "additionalProperties": true
      },
      "InviteBody": {
        "type": "object",
        "title": "InviteBody",
        "required": [
          "email",
          "role"
        ],
        "properties": {
          "email": {
            "type": "string",
            "description": "Any address, with or without an existing account. Trimmed and lower-cased; validated only as `something@something.something` — the real validation is whether the mail arrives."
          },
          "role": {
            "type": "string",
            "enum": [
              "editor",
              "viewer"
            ],
            "description": "Only these two. `owner` is refused with 400: ownership is never granted through a forwardable link."
          }
        },
        "additionalProperties": true
      },
      "InviteResult": {
        "type": "object",
        "title": "InviteResult",
        "description": "Which of the three outcomes happened. `{invited:true, role_updated:false}` covers BOTH the brand-new invitation and the reissued pending one — the two are not distinguished on the wire.",
        "required": [
          "invited",
          "role_updated"
        ],
        "properties": {
          "invited": {
            "type": "boolean",
            "description": "True when an email was sent — a new invitation, or a pending one reissued with a fresh secret and a fresh 7 days on the same row."
          },
          "role_updated": {
            "type": "boolean",
            "description": "True when the address already belonged to a member and the role was applied in place; no email is sent in that case. It is also true when the role sent equals the role they already held, in which case nothing at all was written."
          }
        },
        "additionalProperties": false
      },
      "Invitation": {
        "type": "object",
        "title": "Invitation",
        "description": "A pending invitation as the owner-only list returns it. The secret hash, the accept URL and even `database_id` are all deliberately absent.",
        "required": [
          "id",
          "email",
          "role",
          "expires_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`inv_` + 12 characters.",
            "examples": [
              "inv_4m7pqrs2tuvw"
            ]
          },
          "email": {
            "type": "string",
            "description": "The invited address, lower-cased."
          },
          "role": {
            "type": "string",
            "enum": [
              "editor",
              "viewer"
            ],
            "description": "The role the recipient receives on accepting. Constrained to these two by a database CHECK as well as in code."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "7 days from when the invitation was created OR last reissued. A reissue resets this on the SAME row, so `created_at` can be far older than `expires_at` minus 7 days."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the row was first inserted. A reissue does not move it."
          }
        },
        "additionalProperties": false
      },
      "InvitationRevoked": {
        "type": "object",
        "title": "InvitationRevoked",
        "required": [
          "id",
          "revoked"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "inv_4m7pqrs2tuvw"
            ]
          },
          "revoked": {
            "type": "boolean",
            "const": true
          }
        },
        "additionalProperties": false
      }
    }
  }
}
