data_set_schema
Declare (or clear with schema=null) a datastore's TYPED schema (ADR 0032 §6).
A typed datastore renders as readable cards/records instead of a flat table.
schema = {"fields": [{"key": str, "label"?: str, "type"?: "text|number|date|
datetime|bool|json|object|list|url|email|phone|ref|enum",
"options"?: [str], "display"?: "title", "role"?: "status|metric|note|qualif",
"description"?: str, "meta"?: {…}}],
"key"?: str, "new_rows"?: "create|reject", "description"?: str,
"meta"?: {…}}.
An enum column's allowed values go in options, ON the field
({"key": "status", "type": "enum", "options": ["kept", "rejected"]}) —
that is what constrains the values and what a board groups on. Never in
meta: a key the field admits is REFUSED there (stored, it would do
nothing).
⚠️ The vocabulary is CLOSED: a key its level does not admit is REFUSED —
at the head, on a field, on a sub-field, in of, in lifecycle. The refusal
names the path, the key and the closest known one (read_only → readonly),
or where it belongs (states → lifecycle.states, semantic_search → a
parameter of this call). An unknown key ALREADY stored and left unchanged is
tolerated (named in warning), never one you add or change. Your own
annotations go in meta — an object, allowed at every level, carried as is,
never read, bounded in size. Help text goes in description (note,
help, hint, placeholder are refused). Each level's keys, who reads
each one and the meta bound (meta_max_bytes) are served at
GET /api/datastore/schema/keys.
⚠️ This REPLACES the schema — it does not merge. Any setting absent from the
body you send DISAPPEARS (a field note, a bound, options, the business key and
its UNIQUE index). The response now names what it just removed
(declarations_effacees, with the lost VALUES — it is the only copy left) and
enforced (the validation keys THIS version applies). To EDIT a schema without
risking part of it, use data_patch_schema: it merges by key and cannot
destroy what it does not name.
The response also carries existing_violations: per path (contacts[].email),
the rows ALREADY IN PLACE that the posed schema condemns — rows,
blocking_rows (rows that accept no write at all until fixed), sample_ids
and consequence. Clean them before arming the guard;
existing_violations_scope.complete: false means the row cap was hit and the
counts are floors.
The optional top-level "key" names the field that is the row's BUSINESS KEY
(e.g. "email", "siren"). A write that DESIGNATES by the key — key= passed to the call (a batch, or a single row naming this key), or any write without id on a CLOSED table — modifies the row of an existing key value and creates a new one. A write that ADDS (neither id nor key=) with a key value already present is REFUSED unless it passes upsert=true, which merges onto that row — data_write, REST and oto_upload_url alike. The key never duplicates: its UNIQUE index guarantees it.
⚠️ A column the schema does NOT declare is REFUSED at write, on EVERY table: declaring a column (data_patch_schema(fields=[{"key": …}])) is the ONLY way to add one. ⚠️ The declared format is a CONTRACT on every table: top-level options are enforced, the SHAPE of text/url/object/list/enum values is checked, an unknown layer is refused and a declared sub-record refuses an attribute it does not declare. There is NO head setting for it any more: unknown_columns was REMOVED on 2026-10-05 and is refused — columns and values are always checked.
ONE head setting is left (strict, unknown_fields and key_required were
replaced on 2026-10-02; unknown_columns was REMOVED on 2026-10-05 — no
setting decides what is checked, all are refused):
"new_rows"— whether a NEW row may be born:"create"(default) or"reject", which CLOSES the table: a write that designates NO existing row (noid, and no key value the table already carries) is REFUSED instead of creating one. Needskey. A table often fills up before it has its key, hence the default.data_get_schemaserves it AS APPLIED (reglages). Pass schema=null to switch back to free-table mode.
PRESENTATION — the schema also DRIVES THE UI (there is no visual editor: this tool IS the way to configure how a table looks):
field ORDER in the record view = the order of
fieldshere. Reorder the list to reorder the form.typepicks the WIDGET:date/datetime→ date picker (readable, not a raw ISO string),url→ compact field + open link (never a giant text box),email→ mail field,enum(+options: [...]) → dropdown,bool→ true/false,number→ numeric. Untyped fields fall back to a plain text box, so DECLARE the type when the rendering matters.width: "half"|"full"= the field's width in the record form. Without it the width is derived from the widget — declare it to keep a stable layout.hidden: true= keep the field OUT of the table columns by default (still editable in the record). Use it for opaque ids and technical fields.display: "title"NAMES the row: that field titles the record everywhere the server names a line (work queue, undo, cards) instead of a raw_id. One per table.roleonly steers the dashboard's rendering (metrictiles,noteplaced last,statusbadges) — oto does not read it: alifecycleblock designates its own column, no role needed. ⚠️ The dashboard still titles rows fromrole: "title", which the server no longer reads. On a table meant to look right in BOTH, declare both until they converge.
STRUCTURED RECORDS (ADR 0046 — every layer opt-in):
nested types:
type:"object"+fields:[…](sub-record, e.g. occupant);type:"list"+of:<field-def>(list of scalars or sub-records, e.g. contacts = list of {nom, titre, email}).write validation:
field.required: true, type conformity,field.required_when: {"<field>": "<value>"}(e.g. deliverables required when status="qualified" — ⚠️ to UNDO, clear the gated field AND leave the triggering state in the SAMEdata_writecall: the gate is judged on the MERGED row, so a call that only clears the field while the state still matches is refused),field.max_length: <int>on a SCALAR field, andfield.pattern: "<regex>"for its SHAPE when the size does not separate anything (a code, a snake_case identifier) —re.search, so anchor it yourself (^…$). Apatternapplies on its own: the value it reads is bounded bymax_lengthwhen declared (≤1000), else by 1000 characters — a longer value is refused. The cost of a regex is bounded against that length, and oto refuses what it cannot price — an ambiguous repeated group, a backreference, a lookaround are rejected AT DECLARATION TIME, each naming why.type: "phone"holds a phone number: international (+, country code — E.164) preferred, national digits accepted, spaces, dots and dashes allowed; a sentence or an identifier is refused.type: "ref", "target": <table number>is a LINK column: the cell holds the_idof a row of that table (a list of links:"type": "list", "of": {"type": "ref", "target": <number>}). A name written there is refused — find the row withdata_rowson the target and write its_id. The target must be a table you can read.field.required_layers: ["comment"]refuses a write that leaves a NON-EMPTY value in that column without posting the layer — the value must arrive WITH its provenance, in the SAME call ("col": {"valeur": …, "comment": "where it comes from"}). A null or empty value, and a layer posted alone without a value (a note beside the value, not an assumed empty — that is@empty), trigger nothing — even when the value already in place lacks the layer: that is how you add it ("col": {"comment": …}), without re-sending the value. Applies to sub-fields of objects and of list items too; never toreadonlynor the lifecycle column; never to a column this write does not name. It arms ITSELF — no head setting needed. ⚠️ It does NOT make a comment TRUE: it forces you to NAME a source, which makes a lie checkable — the truth is still established on the documents. Every declared constraint applies (the format as a whole from the date above). A non-conforming write FAILS naming the culprit (max_length reports the actual length AND the bound; pattern reports the value it saw AND the motif). Only what a write SETS is judged: a row whose stored value is already off-format still accepts a write to its OTHER columns — the stale value is named back inhors_type, never a reason to refuse. Fields the caller does NOT write — one question ("whose column is this?"), and each refusal names the field, the reason and where the thing goes:field.readonly: truerefuses a write that CHANGES a value already SET (layers stay open — what another source says goes in<field>.comment); a cell with NO value (key absent,nullor"") can still be filled once,@emptycounts as set, and clearing a set value (null) is a change; ⚠️field.origine: "system"was REMOVED on 2026-09-08 and is now REFUSED like any unknown key. Nothing captures a previous value automatically any more: the origin is set by the call that BRINGS the data, withdonnees_d_origine=true(data_writesays how). Re-sending the SAME value is never a write, so re-emitting a record you just read always passes. Bound the fields meant to hold ONE short value (a job title, a city): a column that collects reasoning stops being groupable/filterable. The bound applies to the keys a write actually SETS, so rows already over it keep working until that field is rewritten — and setting a bound on a table that already overflows answers with awarningsaying how many.lifecycle: the column that carries the block IS the status column (no
roletag needed),lifecycle: {states:[…], transitions:{from:[to…]}, terminal?:[…]}— unknown state or undeclared transition is refused. Eachtransitionsvalue is a LIST, even for one destination:{"a": ["b"]};{"a": "b"}is refused at declaration;terminalis a LIST too. ⚠️ It no longer releases the work-queue claim: writing a "final" state does NOT free the row (#317). Release is a gesture of the LOCK — data_release, or closing your run — never an inference from a business value.step labels:
lifecycle.labels: {"a_qualifier": "To qualify", …}— the name a screen shows for each state instead of its code. Presentation only: no write reads it, a row still carries the code. Each key must be a state ofstates(an unknown one is REFUSED and named), each value a non-empty string of at most 60 characters; a state without a label is fine (the screen derives one from the code).work-queue ceiling:
lifecycle.max_claims: <int >= 1>+lifecycle.abandon_state: "<terminal state>"— a row claimed that many times WITHOUT a successful write leaves the queue in that state, with a platform reason in_abandon(ending with what the last attempt hit; its run in_abandon_run). Both go together: a ceiling without an abandon state, or an abandon state that is not terminal, is REFUSED here. Counter (_claims) resets on the first successful write to the row. Undeclared, a platform default ceiling (3) still applies: the row is set aside with its reason in_abandon, its status left untouched.lifecycle.claimable: {col: val | {op: val}}(filtergrammar) = the rows the queue SERVES: no claim hands out a row outside it, whateverfiltersays.passes:
lifecycle.advance: {"societe": "dirigeant", "dirigeant": "email"}— the counterpart ofabandon_state. A claimed row RELEASED (data_release, run_finish) after at least one successful write since its claim moves to the next state, as a platform revision of that run. Agents claim by state and never write it; if a pass writes the state itself, its write wins. No write, no move (the ceiling applies). Each step must be a declared transition, never from a terminal state — refused here otherwise.
SEMANTIC SEARCH (#67 V2.2 — opt-in per datastore): pass semantic_search=true
to make this datastore's ROWS findable by MEANING via oto_search (not just exact
words), embedding each row (has a per-row cost → off by default; enable it on the
tables you actually search by concept). false turns it off and purges the
embeddings. Passing ONLY semantic_search leaves the schema untouched.
Tool call template
Replace each placeholder with a value matching the input schema. Only required parameters are included; add optional parameters as needed.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"datastore": "<datastore>"
},
"name": "data_set_schema"
}
}Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| _org | No | Org (id) de CET APPEL. `oto_list_orgs()`. | |
| _group | No | Équipe (id) de CET APPEL, sous son org parente. | |
| schema | No | the schema object, or null to clear it. Head key `new_rows: "create"|"reject"` (`unknown_columns` is refused: no setting any more); a field may carry `readonly: true` (a value once set is locked, an empty cell fills; layers open). ⚠️ `origine` was REMOVED on 2026-09-08 and is refused — the origin is set by the call that brings the data (`donnees_d_origine=true`), not by a schema format. | |
| _run_id | No | OBLIGATOIRE à chaque appel dès `run_start` ou `data_claim_next` : sans lui, l'écriture sur une ligne réservée est refusée. Hérité seulement si le serveur tient un run actif — n'y compte pas. | |
| _project | No | Projet (id) de CET APPEL — jeton PRIMAIRE. `oto_project(op='list')`. | |
| datastore | Yes | target datastore (must exist; you must have write access). | |
| semantic_search | No | true/false to toggle semantic row search; null = leave as is. |