@lotics/cli 0.315.18 → 0.316.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,10 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
4
4
 
5
5
  | Command | What it does |
6
6
  |---|---|
7
- | `lotics` / `lotics --help` | Show full help: capabilities, the verb list (§ COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics model --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |
7
+ | `lotics` / `lotics --help` | Show full help: capabilities, the verb list (§ COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics app --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |
8
8
  | `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |
9
9
  | `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open — `https://lotics.ai/cli_login/<request_id>`, also mailed — and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential — `docs` among them — claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |
10
+ | — | **A flag is refused on a verb that does not read it**, naming the verbs that do, before any request or credential: `--check` on `lotics run` would otherwise be parsed and ignored, and the write it was meant to stop would run. `--json`, `--workspace`, `--api-key` and `--timeout` are read by every verb. |
10
11
  | `lotics auth api-key [key]` | `whoami` → **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |
11
12
  | `lotics auth web` | Send a magic link email to access the web app (requires auth) |
12
13
  | `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key — read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |
@@ -27,33 +28,31 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
27
28
  | `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or stdin behind the `-` sentinel (`cat args.json \| lotics run <tool> -`) — both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). **Stdin is asked for, never guessed.** `lotics run <tool>` with no payload runs the tool with no arguments and returns at once. `lotics report` takes the same sentinel. In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone — the error names both escapes. |
28
29
  | `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
29
30
  | `lotics run <tool>` — **file cells** | A file in a tool's result carries its `fil_…` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file — this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |
30
- | — | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_agent`, `run_app_query`) and every one that changes an app (`set_app_queries`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command exists only for work that touches a local file: `model apply`, `model check`, `model pull`, `app create --custom`, `app pull`, `app deploy`. |
31
- | — | **An app's queries and workflows take more than their params and inputs** (`lotics docs agent_surface`). `run_app_query '{"app_id","alias","params"}'` also takes `filters` and `sort` over the query's output columns, `fields` (the columns to return), `count` with `count_by` or `count_where`, `aggregate`, `limit` (200 rows at most; 50 where none is stated) and `cursor`, and `runs` — up to 20 reads in one call, each `{"alias", …}`, a refused one answered with its `error`; one call's rows total at most 262,144 bytes. `run_app_workflow '{"app_id","alias","inputs"}'` also takes `preview: true` — nothing is written; the answer is what the run would return, with the calls it would make under `preview.writes` and the parts of the answer it could not say under `preview.unpredicted`; a workflow no preview is offered of answers `status: "not_previewed"` — and `runs` — up to 50 runs in order, the first refused, failed or not previewed ending the call, an input `{"from_run": <index>, "path": "data.<key>"}` reading an earlier run's answer. |
32
- | — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` — a preview asked for and not given) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |
31
+ | — | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_query`) and every one that builds: `set_tables` (a list of table documents), `set_app` (one app), `set_app_queries`, `set_app_workflow`. A command exists only for work that touches a local file: `app apply`, `app create --custom`, `app pull`, `app deploy`. |
32
+ | — | **An app's queries and workflows take more than their params and inputs** (`lotics docs agent_surface`). `run_app_query '{"app_id","alias","params"}'` also takes `filters` and `sort` over the query's output columns, `fields` (the columns to return), `count` with `count_by` or `count_where`, `aggregate`, `limit` (200 rows at most; 50 where none is stated) and `cursor`, and `runs` — up to 20 reads in one call, each `{"alias", …}`, a refused one answered with its `error`; one call's rows total at most 262,144 bytes. `run_app_workflow '{"app_id","alias","inputs"}'` also takes `dry_run: true` — a rehearsal on the real inputs, nothing written or sent; the answer is what the run would return, with the reads it made under `dry_run.reads`, the calls it would make under `dry_run.writes` and the papers it rendered under `dry_run.papers` and the parts of the answer it could not say under `dry_run.unpredicted`; a workflow no dry run is offered of answers `status: "not_previewed"` — and `runs` — up to 50 runs in order, the first refused, failed or not previewed ending the call, an input `{"from_run": <index>, "path": "data.<key>"}` reading an earlier run's answer. |
33
+ | — | **The exit code reports the WORK, not just the call — for the tool that RUNS one.** `run_app_workflow` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` — a dry run asked for and not given) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |
33
34
  | `lotics run <tool> '<json>' -o <dir>` | Also save every **signed link** the answer carries — an object with a `url`, a `filename` and the `expires_at` its signature ends at, at any depth, such as `screenshot_app`'s images. Each is fetched into `<dir>` (created if missing) under its `filename`, taking a free spelling beside a file of that name already there, and a link's `snapshot` text is written beside it as `<name>.txt`. The written paths follow the answer on **stdout**, one per line (on stderr under `--json`, whose stdout stays the one object). A link is signed for an hour; one the store no longer serves exits non-zero. A workspace file states no `expires_at` and is left to `lotics file download`. |
35
+ | `lotics run set_member '{"members": {"<email or member id>": {"role": "member", "name": "…"}}}'` | Invite, rename, remove (`null`) or restore members, admin only: the whole map is checked before an email is sent. A member's `lotics tools` does not list it, nor `set_group` or `set_workspace`. View-as is the web app's preview alone; the CLI acts as the signed-in member. |
34
36
  | `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |
35
37
  | `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes — harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |
36
38
  | `lotics file upload <file\|dir...>` (alias `lotics upload`) · `--stdin` · `--base64` · `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files`, several such files to a request up to half that route's 100 MiB cap; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose — one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
37
- | `lotics file download <file_id> [<path>]` · `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there — the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once — a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_key> [-o <dir>]` spreads every file on a record's file field over a DIRECTORY — there is no single file for N files to be. |
39
+ | `lotics file download <file_id> [<path>]` · `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there — the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once — a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_alias> [-o <dir>]` (the field named by the alias `get_record` shows it under) spreads every file on a record's file field over a DIRECTORY — there is no single file for N files to be. |
38
40
  | `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first — id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset — pass it back as given — so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to "what is in here" short of reading Postgres. |
39
41
  | `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. Deleting an original deletes every version of it; a file's current version cannot be deleted on its own, and a document template's file, or a version of it, only by a member who manages the template. **Refused while a record cell, a comment, a knowledge doc, a document template, a voice session or a pending change request still references the file or a version it takes along** — the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what "deleted" means here. There is no `lotics delete`: the verb needs its noun. |
40
- | `lotics knowledge list [--include-hidden \| --deleted]` | `GET /v1/knowledge_docs` — a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. **`--deleted` is the one tool-backed form**: it calls `list_knowledge` with `deleted: true` and prints the deleted docs you can manage (id, name, deleted time, description); it cannot be combined with `--include-hidden`. |
41
- | `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \| --content <str>)` | Read the body client-side (a file XOR an inline string — exactly one required), then call `create_knowledge` with `{ name, description, content, tags? }` (description defaults to `""`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |
42
- | `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) → the doc with its **hydrated `content`** (the one content-read path for a non-sandbox client). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |
43
- | `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set — the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |
42
+ | `lotics knowledge list [--include-hidden \| --deleted]` | `GET /v1/knowledge_docs` — a table of id, name, tags, description (`--json` for the docs). **REST, not `get_workspace` kind `knowledge`**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. **`--deleted` is the one tool-backed form**: it calls `get_workspace` with `kind: "knowledge"` and `include_archived: true`, page by page, and prints the deleted docs you can manage (id, name, deleted time, description); it cannot be combined with `--include-hidden`. |
43
+ | `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \| --content <str>)` | Read the body client-side (a file XOR an inline string — exactly one required), then call `set_knowledge` with `{ name, description, content, tags? }` and no `knowledge_doc_id`, which makes a doc (description defaults to `""`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |
44
+ | `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) → the doc with its **hydrated `content`** (the one whole-content read for a non-sandbox client; the tool `read_knowledge` reads a line range). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |
45
+ | `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `set_knowledge` with a `knowledge_doc_id` and **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set — the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |
44
46
  | `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` — one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |
45
- | `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** — the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches — while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as "everything". |
47
+ | `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** — the Library's list, and the corpus `get_workspace` lists and its `search` searches — while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as "everything". |
46
48
  | `lotics knowledge rm <id...>` | Delete docs via `delete_knowledge` (`{ knowledge_doc_ids }`), in one call: every id must be a live doc you manage that no app or workflow names, or nothing is deleted. Sharing and history are kept. The REST execute path does not gate `needsApproval`, so this runs unattended. |
47
49
  | `lotics knowledge restore <id...>` | Bring deleted docs back via `restore_knowledge` (`{ knowledge_doc_ids }`), in one call: every id must be a deleted doc you manage, or nothing is restored. Refuses the no-argument form. |
48
- | `lotics setup <model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes — `--name` and `--timezone` apply), then applies the model to its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** — it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as — that address IS the account it holds, not a second one. The file is a workspace MODEL, and it is read and checked before an account is created — the design decisions an apply would refuse it for too (`lotics docs design`), since a new workspace holds no rows to change them — because a file with a typo in it must not leave an organization behind. Then it is `lotics model apply` run on the new workspace: its tables, rows and apps; the sign-in link lands on its app when it has one, else on the workspace's app list. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently writes into an org the caller did not name — the message says how to do each thing on purpose. Without it, `setup` applies the model to the account you already have. A path positional after the file is accepted and IGNORED with a warning — `setup` writes nothing to disk — so a prompt that passes one still runs. **`--json` prints one object on stdout and nothing else** — what `lotics model apply --json` does (`tables`; `apps`, each with `alias`, `app_id`, `version_id`, `origin`, `address` and `findings`; and `findings`) plus `organization_id`, `workspace_id` and `signin_url`, and a `warnings` array carrying everything the prose form would have said out of band, such as a sign-in link that could not be minted. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup …`. |
49
- | `lotics model apply <model.json> [--app <alias> ...] [--json]` | **The model, applied to this workspace, through the `apply_model` tool.** The file is read and checked against the model's own rules with the validator the server runs, every problem in one run, before anything is uploaded. **The apply refuses an app leaving out a treatment its rows call for** (`lotics docs design`) — judged by the server beside the workspace's rows, before it writes anything — until the app adopts it or states why not under the `declines` key the refusal names; the refusal carries each refused app's patch adopting its decisions, to merge in the order printed. Where the model's own rules pass, one refusal carries everything decidable before the first write — those decisions, what compiling each app finds, a field that cannot leave its table and what the caller may not change — so one pass fixes them. Documents a row attaches by a path beside the file are uploaded first and the rows sent with their `fil_` ids; a path this workspace already recorded keeps its id, so a re-apply uploads nothing twice. **Where the file was pulled (`-o`) or applied in this workspace before, only what it changed since is sent**, as a `patch`: what changed in the workspace since and the file does not touch stays. An item named by its `alias` that the file puts in a new place — an act among an app's acts, a field between two others — is sent as a move beside the patch: inside an app it is built into the app, an option of a select takes its new place among that select's options on the table, and a table's fields and views, the roles, the templates and the apps themselves keep the order the workspace holds, said as a note. A list whose items carry no `alias` — an app's columns, a section's fields, the sections — is sent whole in its new order. What the file takes out is sent as a removal: it goes where an apply rebuilds it (an app's acts and columns, a write rule), a field leaves its table, and it is refused, naming the tool that deletes it, where an apply never deletes it (a table, a view, an app); an option leaves every app and stays on its table, said in the notes. **A field the file no longer states leaves its table** — a field an earlier apply stated, or one the pulled file stated — only where no live row holds a value in it and nothing outside the model's own apps reads it: no formula, rollup or lookup, no figure's unit or currency, no `default_from` copy, no unique set, no query, agent, table check or workflow body of the workspace. A row in the trash does not count, since no write can clear it: its value goes with the field, and the notes say how many rows it went for. Otherwise nothing is applied and the refusal names the field, how many live rows hold a value and what reads it; a workflow body the author wrote on an app the apply rebuilds that still names the field keeps it on the table, said in the apply's findings, and the next apply sends its removal again until nothing names it. A field such an app names leaves only once the app is rebuilt, with each leaving field it reads, so where one of them reads a field the file changes, nothing is applied and the refusal names both: apply the file without that change first. A field of the table no model ever stated is left as it is. **A label the file changes is a rename**: a field's and a select option's label, and a table's description, are written onto the table as the file states them, each named in the notes. **A declared workflow whose `inputs` or `outputs` the file changes** keeps the body set on it where the body still meets them; where it does not, the apply lands and the workflow stands on no body again — every call refused — until `set_app_workflow` sets one that does, said on the app's line. A file stating a `null` a patch would read as a removal sends the whole model, said on stderr, and so does a reorder sent to a server that takes no move. A patch builds the apps it states or moves an item of, each other app whose build it changes and each app whose live version names a field or option its tables no longer hold, `--app` naming others; one whose every key and move lies inside one app's body leaves the tables as they stand — its `tables` is empty — and checks and builds that app alone, beside any other app whose build reads what it changed. The copy each change is read against is kept per workspace and file under `~/.lotics/model_bases`: an apply narrowed by `--app` leaves in it the other apps as they were, so the next apply sends their edits again, and an apply that fails — refused, or cut off — leaves none, so the next sends the whole model. Then the tool adopts or creates every table (the table this workspace bound the entity to, else an existing table of the same label, is ADOPTED and given the fields, options and views it lacks; no stored value changes), writes first rows only where every bound table is empty, and mints a new version of each app the model declares, or the patch builds — `--app` (repeatable, comma-separated) narrows which apps, while the tables are applied whole. Prints one line per app it built — alias, `app_id`, `created`/`updated` with the version minted or `unchanged`, and the address it is served at — then the model's notes once, as the server states them. **What it would change, writing nothing, is `lotics model check`.** **A rollback restores an app's earlier version** (`lotics run rollback_app`); table changes and data writes stay. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` on stdout, or `{ok: false, findings}` when the file does not check. |
50
- | `lotics model check <model.json> [--app <alias> ...] [--json]` | **What applying the model would change, through the `check_model` tool, writing nothing.** The file is read and checked as `model apply` checks it, then sent the same way — whole, or as what it changed since its base — and nothing is uploaded or kept. It prints what the apply would do to the schema (what it would add, each kind counted — tables, fields, options, constraints — what it leaves as the workspace has it, what the two disagree about), which apps it would create or update, what it would refuse an app for — the decisions among its findings, read beside the workspace's rows, with each refused app's patch — and what it would refuse of the model beside its apps (a field that cannot leave its table, a template nothing stores), all in the one run, exiting 1 when it would refuse anything; its notes name each field it would take off a table and each label it would rename; workflow bodies are checked only at apply, since they name fields a check has not created. `check_model` only reads, so a key holding read access alone runs this where `model apply` is refused, and so does a private instance whose licence admits reads only: it needs `schema:read`, and `data:read` when the file states an app or is sent as what it changed. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` — each table and app what the apply would do, an app the patches change carrying its body as they leave it as `draft` — and `designs`, each refused app's patch in merge order, or `{ok: false, findings}` when the file does not check. |
51
- | `lotics model pull [-o <model.json>]` | **This workspace's model, rebuilt from what owns each part** — the tables, fields, options, templates and roles the workspace holds, how rows are recognised, and each app's body from its current version — through the `get_model` tool, as the file `model apply` reads: to stdout, or to the file `-o` names. What the workspace holds that a model cannot state is printed on stderr, never written into the file. Applying what it wrote changes nothing. With `-o`, what it wrote is the copy the next `model apply` of that file reads its changes against. |
52
- | `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`), scaffolds a Vite + React + TypeScript project into `[path]` (default `./<name>`, refused when not empty — before the app row exists) that depends on `@lotics/app-sdk` alone and draws with plain React, and installs it (`npm install --ignore-scripts`), then writes the declarations of the app's live bindings (`get_app_types`) into `.lotics/`, which the project's `tsconfig.json` includes. `package.json#lotics` names the app and its workspace, which is how `app deploy` in that directory finds both. The app has no version until the first `lotics app deploy`. `--custom` is required: an app the runtime draws from a model is made by `lotics model apply`. The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |
53
- | `lotics app pull [app_id] [path]` | **A custom-code app's live source, as a project ready to deploy on it.** The target is `[path]`, else this directory when it is the app's project (or no app is named), else `./<name>`. **The app's own project is brought up to date in place** — but only when it holds no edit since the version `package.json#lotics.current_version_id` names: its source (packed as a deploy packs it) is compared with that version's archive, ignoring `.lotics/` and `package.json#lotics`, and any difference refuses the pull, naming the changed files; a pull never merges, so local work is never lost. Already at the live version is a no-op that says so. **An empty or new directory receives the source whole**; any other directory is refused. Downloads come from `GET /v1/apps/{id}/versions/{version_id}/source`. `package.json#lotics` is then set to exactly the app, its workspace and the pulled version, dropping every other key an older CLI wrote there, so the next `lotics app deploy` builds on the live version; then `.lotics/` is written (`get_app_types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). A JSON app has no source tree: the server's refusal names `get_model`, and `lotics model pull` is its pull. |
54
- | `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`get_app_types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent; a type error in the app's own files stops the deploy, one inside an installed package is reported and does not) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged — those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live, and names `lotics app pull`: run in this directory, it brings an unedited project up to date, and lists the files a project with edits changed, to carry over into a fresh pull. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |
55
- | `lotics docs` \| `lotics docs <area>[/<section>]` \| `lotics docs [<area>[/<section>]] --grep <text>` | **This CLI's own references, carried inside the binary** — the model reference, this index, and every doc under `docs/` — so the doc a reader opens describes the binary answering, listed by the job a reader comes to do. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. **A reference the binary's copy lacks, or a part of one the server serves, is read from the server's `docs` tool** with this machine's credential, after the copy's refusal — a page the server added since this binary was built, which a server refusal can cite. A name matching more than one reference, or a part missing from a guide to this CLI, is answered by the copy alone. **`--grep` searches the references the server serves** (not this CLI's own guides, which it refuses), with this machine's credential, narrowed to the area or section named: literal text unless `--regex`, with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` — the dialect `grep_knowledge` reads — each hit under the address that opens its page; any of those options without `--grep` is refused. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |
50
+ | `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`), scaffolds a Vite + React + TypeScript project into `[path]` (default `./<name>`, refused when not empty — before the app row exists) that depends on `@lotics/app-sdk` alone and draws with plain React, and installs it (`npm install --ignore-scripts`), then writes the declarations of the app's live bindings (`GET /v1/apps/{id}/types`) into `.lotics/`, which the project's `tsconfig.json` includes. `package.json#lotics` names the app and its workspace, which is how `app deploy` in that directory finds both. The app has no version until the first `lotics app deploy`. `--custom` is required: a JSON app is made by `lotics app apply` (the tool `set_app`). The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |
51
+ | `lotics app pull [app_id] [path]` | **An app's live definition, pulled.** The app is read with `get_app`: its author is shown a body, a JSON **tree** (with the addresses of its nodes) pulled as that tree and a custom-code app (the identity alone) as its source; an app whose stored tree cannot be read is refused with the reason `get_app` gives, and so is an app that shows the caller no body (the caller does not author it, or it has no version). **A JSON app**: the tree is written as JSON to `[path]` (a directory receives `./<name>.app.json`; default `./<name>.app.json`), and the app's id and current version beside it in `<file>.lotics.json` (`{ "app_id": "…", "version_id": "…", "digest": "…" }`, the digest being the file as the CLI last left it; a record without all three is refused by `app apply`, naming `lotics app pull`) — a custom-code project records both in `package.json#lotics`, a single file has no such place — so the next `lotics app apply <file>` edits that app, and is refused if the app has since moved past that version. An existing file is overwritten only when its `.lotics.json` names this same app and the file is unchanged since it was last pulled or applied; a file with edits, or whose record names the app but no digest, is refused, naming `--force`, which overwrites it. `--force` is a flag of `app pull` alone. `--json` prints `{workspace_id, app_id, path, outcome}`. **A custom-code app**: its live source, as a project ready to deploy on it. The target is `[path]`, else this directory when it is the app's project (or no app is named), else `./<name>`. **The app's own project is brought up to date in place** — but only when it holds no edit since the version `package.json#lotics.current_version_id` names: its source (packed as a deploy packs it) is compared with that version's archive, ignoring `.lotics/` and `package.json#lotics`, and any difference refuses the pull, naming the changed files; a pull never merges, so local work is never lost. Already at the live version is a no-op that says so. **An empty or new directory receives the source whole**; any other directory is refused. Downloads come from `GET /v1/apps/{id}/versions/{version_id}/source`. `package.json#lotics` is then set to exactly the app, its workspace and the pulled version, dropping every other key an older CLI wrote there, so the next `lotics app deploy` builds on the live version; then `.lotics/` is written (`GET /v1/apps/{id}/types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). |
52
+ | `lotics app apply <file> [--app <app_id>] [--check] [--json]` | **A JSON app, one at a time, through the tool `set_app`.** The file is an app's tree (`lotics docs model`: the App node with the nodes under it, its tables and templates named by alias). **No app known** (no `--app`, no `<file>.lotics.json` beside it): the file is sent as `{ "body": … }` to make a new app, named by the body's `name`, and the id made is printed, then recorded with its version in `<file>.lotics.json`. **An app known**: its current tree is read (`get_app`) and the **RFC 7396 merge patch** that turns it into the file is sent as `body` with the `app_id` and the version the patch was computed against as `base_version_id` — a key the file drops is `null` in the patch, a list is replaced whole, what the file leaves alone is left alone, and a tree the app already has sends nothing and says so. **A file pulled at a version the app has since left is refused** before anything is sent, naming both versions and `lotics app pull <app_id> <path>`: pull the current tree into another path, carry the edits over, apply that file. `--app` naming an app other than the sibling file's is refused too. A write that races the apply is refused by the server, which names the app's current version. A file that sets an object key to `null` is refused naming the key, since a merge patch reads `null` as a removal. `--app` names the app, else the sibling file does; an app whose `get_app` shows no body (it is not yours) or whose stored tree cannot be read is refused, and a custom-code app takes its identity (name, description, icon, colour) only, and no `<file>.lotics.json` records it, since an identity write makes no version — its source deploys with `app deploy`. **The file is never rewritten**: a node the tree leaves out draws as the rows call for it and is stored nowhere (`lotics docs design`). It prints what `set_app` answered and reports its `outcome`: its notes and findings, every finding at once, and the saves, adds and Deletes the write grants and removes. **`--check` sends `dry_run`** (a flag of `app apply` alone, as is `--app`) — what would refuse the tree, and what it would grant and remove — and writes nothing, the file and its sibling included; an `outcome` of `refused` exits 1. A tree the app already has, or one `set_app` answers `unchanged`, records the version the app stays at in the sibling. **To go back to an earlier version, apply the earlier tree again.** Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, app_id, outcome, write}`, `outcome` being `set_app`'s: `created`, `updated`, `unchanged` or `refused` (with `--check`, what it would be). |
53
+ | `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`GET /v1/apps/{id}/types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent; a type error in the app's own files stops the deploy, one inside an installed package is reported and does not) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged — those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live, and names `lotics app pull`: run in this directory, it brings an unedited project up to date, and lists the files a project with edits changed, to carry over into a fresh pull. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |
54
+ | `lotics docs` \| `lotics docs <area>[/<section>]` \| `lotics docs [<area>[/<section>]] --grep <text>` | **This CLI's own references, carried inside the binary** — the model reference, this index, and every doc under `docs/` — so the doc a reader opens describes the binary answering, listed by the job a reader comes to do. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. **A reference the binary's copy lacks, or a part of one the server serves, is read from the server's `docs` tool** with this machine's credential, after the copy's refusal — a page the server added since this binary was built, which a server refusal can cite. A name matching more than one reference, or a part missing from a guide to this CLI, is answered by the copy alone. **`--grep` searches the references the server serves** (not this CLI's own guides, which it refuses), with this machine's credential, narrowed to the area or section named: literal text unless `--regex`; a literal search that finds nothing while the pattern read as an expression matches (`a|b`) returns those lines under a note saying so, and `--literal` keeps the literal zero; with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` — the dialect the knowledge lines of `get_workspace` with `search` are read in — each hit under the address that opens its page; any of those options without `--grep` is refused. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |
56
55
  | `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script — the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe — a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |
57
- | `lotics docs model` \| `lotics docs model/<section>[/…]` | **The model reference, from inside the binary** — it describes this CLI's own model checker, at this CLI's version; `lotics docs` lists it under "Build an app", after `design`. Its first page is what a model composes with, the working order (jobs → entities and fields → `records` → one app per job → `model apply`) and the section addresses; every page of it is whole. Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, `records` (how a row of each entity is recognised), `write_rules`, `apps` (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `"<entity-alias>:<ref>"`), the rules, and one complete worked example; it points at `https://lotics.ai/presets/index.json` for complete example models of several trades. **Offline, no account.** |
56
+ | `lotics docs model` \| `lotics docs model/<section>[/…]` | **The reference for tables and apps, from inside the binary** — at this CLI's version; `lotics docs` lists it under "Build an app", after `design`. Its first page is what the documents compose with, the working order (jobs → tables and fields → `records` → one app per job → one `set_tables` call with every table, then every `set_app` call) and the section addresses; every page of it is whole. Every key of a table document and an app body, every field `type` with the config each one needs, the option / view / inline-template shapes, `records` (how a row of a table is recognised), `write_rules`, the apps (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `"<table-ref>:<row-ref>"`), the rules, the caps of one `set_tables` call, and a worked example; it points at `https://lotics.ai/presets/index.json` for example files of several trades, which an agent reads and passes to `set_tables`. **Offline, no account.** |
58
57
  | `lotics report '<json>'` \| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** — `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` — invoking it IS the consent that passive collection needs an opt-in for — but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** — a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |
59
58
 
@@ -10,8 +10,8 @@ under **Fields**, what each field type takes.
10
10
 
11
11
  ## One fact, one column
12
12
 
13
- **Read the table's existing fields first, and add nothing that stores a fact the table already
14
- stores.** A value written as text and the same value held as a `select_record_link` are one fact in
13
+ **Read the table's existing fields first (`get_table`), and add nothing that stores a fact the table already
14
+ stores.** A value written as text and the same value held as a `link` are one fact in
15
15
  two columns — a customer's city typed into a text box beside a link to the city record, a status
16
16
  word beside the select that decides it, a total beside the formula that computes it.
17
17
 
@@ -28,6 +28,8 @@ it runs.
28
28
  description that says which one is real.
29
29
  - **Empty is not the same as redundant.** A field nothing fills may still be the only home for a
30
30
  real distinction — read what it MEANS before removing it.
31
+ - **A stored field becomes required only once every row holds a value**, so it always holds one: fill the
32
+ empty cells first, then set it. A computed field's flag is checked as each row is written.
31
33
 
32
34
  ## One entity, one table — and the test is measurable
33
35
 
@@ -43,22 +45,19 @@ on suspicion is a large repoint bought for nothing. So do not argue it in the ab
43
45
  None means the split is holding. One means it has broken — the usual cause is a party you begin to
44
46
  invoice as well as buy from — and the fix is to merge before a second screen depends on the copy.
45
47
 
46
- Worth writing as a test rather than a note, because a note about a condition nobody re-checks goes
47
- stale in silence.
48
-
49
48
  ## One vocabulary wherever values are COPIED between tables
50
49
 
51
- Two `select` fields for one concept carry DIFFERENT option keys even when their labels match — keys
52
- are minted per field. So anything moving a value between them needs a hand-written key map.
50
+ Two `select` fields for one concept carry different option keys even when their labels match. Copy an option by its
51
+ alias or label: the record tools match an option by alias or label, so a value copied by alias lands
52
+ on the option of that alias on the other field. Prefer a link to a shared reference table where the set is open or
53
+ growing. Where one side holds more values than the other, the wider side must **refuse** what the narrower one cannot
54
+ express rather than quietly picking the nearest value.
53
55
 
54
- That map is code. Put it in one named module with a test; written inline at the copy it is invisible,
55
- untested, and silently wrong the first time somebody renames an option, because a rename leaves the
56
- key intact and the map still compiling. Prefer a link to a shared reference table where the set is
57
- open or growing; keep a map only for a small closed set.
56
+ A value read through a link or a lookup reads exactly as the same field would on its own row: an option written by its alias in
57
+ a body and read as its key, a member or a link by its id. A value a check matches
58
+ across tables is a link to one table: two selects never compare, and a save that compares them is refused.
58
59
 
59
- Where one side genuinely holds MORE values than the other, that is not drift — it is the model
60
- telling the truth. The wider side must **refuse** what the narrower one cannot express rather than
61
- quietly picking the nearest value.
60
+ A lookup reads as one flat list of the values it looks up, in a check and in every body: `first(record.x)` is the one value through a one-row link and `includes(record.x, v)` asks whether any row holds `v`; two selects never compare, so a value matched across tables is a link to one table. A single member, a single select, a number or a text reads as the value itself, and `first(...)` of one is refused when the body is saved. A rollup that adds up (a sum, a count) over no rows is 0, never empty, as `sum` over an empty list is; a min, a max or an average over no rows is empty.
62
61
 
63
62
  ## A copy boundary accounts for EVERY source field
64
63
 
@@ -80,53 +79,47 @@ row beside it.
80
79
 
81
80
  ## Say what makes two rows the SAME row
82
81
 
83
- Declare the natural key in the table's description.
84
-
85
- Anything that imports, reconciles or de-duplicates has to decide identity, and with no declared key
86
- it falls back to comparing displayed text — which is how one company arrives three times under three
87
- spellings. Name the key: a reference number, a tax id, a link plus a period. Then match on `rec_…`
88
- and `opt_…`, never on rendered labels.
89
-
90
- ## A state's HISTORY is rows, not columns
82
+ State it as the table's `natural_key` (`model/write-rules`); a text key is also `unique`.
91
83
 
92
- A `changed at` column says only how long a row has been where it is now — the next move overwrites
93
- it — and a date column per state holds until something re-enters a state it already left.
94
-
95
- Measuring time-in-state or conversion needs one ROW per move: a link to the subject, the state left,
96
- the state entered, when. Hold those states as the source field's own `opt_` keys so the log carries
97
- no second vocabulary, and write the row in the workflow that makes the move.
98
-
99
- ## Keep derived chains shallow
84
+ Anything that imports, reconciles or de-duplicates has to decide identity, and with no declared key it falls back to
85
+ comparing displayed text — which is how one company arrives three times under three spellings. Name the key: a reference
86
+ number, a tax id, a link plus a period. Then match on record ids and option aliases, never on rendered labels.
100
87
 
101
- Formulas and rollups are computed and STORED when a row is written, and one that reads another
102
- recomputes with it. A rollup over a formula over a formula is paid three times on every touch, and
103
- again for every row upstream of it.
88
+ ## A stage's moment is a date on the row
104
89
 
105
- **Depth costs more than row count.** This is the optimisation lever that actually exists here; row
106
- scanning is the platform's problem, chain depth is yours.
90
+ Who changed what, and when, is the platform's audit trail, which an agent already reads (`query_audit_logs`) under
91
+ its own access to the table — so it is never a table of yours, and an app shows none of it: a history an app shows is
92
+ table-backed (`records.status.dates`, `records.status.history`, an event table an act makes).
107
93
 
108
- ## Changing a money formula on a live table
94
+ When the business reports, filters or prints the moment a row reached a stage (completed on, cancelled on), that
95
+ moment is a `date` field of the row, as `completed_at` and `canceled_at` are in SQL. State it as `records.status.dates`
96
+ (`model/status`): option → date. The record write path stamps it as a row is made at the option or moved into it,
97
+ whoever writes, so no one has to remember; a value the same write states wins, which loads past data as it happened. The
98
+ platform never clears a stamp: each date is when the row last entered the option, and leaving keeps it, and putting a
99
+ change back restamps nothing. A date range is a span, never a moment, so it is refused. It is the one way a status is
100
+ timed: a stage's `within` runs from its date, a straight-line process is one select whose options are its steps, each
101
+ dated, and the stage is never read off a run of dates.
109
102
 
110
- Formula edits recompute every row, so the only honest proof that one changed nothing it should not is
111
- the numbers themselves:
103
+ `records.status.history` is for a business that reports on every move — moves per person, or time in a stage a row
104
+ re-enters — and the platform appends one row per move. An event that carries facts of its own (a reason, an amount, a
105
+ signature, an inspection's findings) is its own table with those fields, made by the act that records it.
112
106
 
113
- 1. Snapshot the affected totals to a file.
114
- 2. Make the change.
115
- 3. Diff. Identical is the pass.
107
+ ## Keep derived chains shallow
116
108
 
117
- And when a formula gains a new field, **test that the value IS the one you want, never that it
118
- differs from it** — an empty cell reads as `""`, which differs from every option key, so the inverted
119
- spelling silently zeroes every row written before the field existed.
109
+ Formulas and rollups are computed and STORED when a row is written, and one that reads another recomputes with it. A
110
+ rollup over a formula over a formula is paid three times on every touch, and again for every row upstream of it.
111
+ **Depth costs more than row count**: chain depth is yours to keep down, row scanning is the platform's.
120
112
 
121
113
  ## Fields
122
114
 
123
- What `create_table` (on the CLI or in chat) and `update_table` take in `add_fields`, and `update_table` in `update_fields`.
115
+ A field is stated inside its table's document in a `set_tables` call, keyed by alias. Every field `type`, the
116
+ keys each one takes (notation, defaults, options, links, formulas, rollups, lookups, copies) and a worked
117
+ example are in `docs path="model/field"`; this page holds the choices around them.
124
118
 
125
119
  ### Types and formats
126
120
 
127
- `type` is one of `text`, `number`, `date`, `boolean`, `select`, `select_member`,
128
- `select_record_link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`. `button` is retired: an
129
- existing button field keeps running, but none is created, converted to or edited.
121
+ `type` is one of `text`, `number`, `date`, `boolean`, `select`, `member`,
122
+ `link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`.
130
123
 
131
124
  A URL, an email, markdown, a checkbox, a datetime, money, a quantity or a percentage is a `format`
132
125
  or a `notation` on another type, never a `type`:
@@ -143,142 +136,95 @@ or a `notation` on another type, never a `type`:
143
136
  | Datetime | `{ type: "date", format: "datetime" }` |
144
137
  | Date range | `{ type: "date", format: "date_range" }` |
145
138
 
146
- ### Properties
147
-
148
- A type's properties sit **directly on the field object** — there is no `config` wrapper. A property
149
- may itself hold an object (`formula`, `aggregate_option`, `filter`, `order_by`); its inner keys stay
150
- inside it. Each type takes only its own:
151
-
152
- - **text** — `format?` (`"text"` | `"link"` | `"markdown"`), `unique?`, `default_value?` (a string).
153
- - **number** — `notation?`, `default_value?` (a number). `notation` is how the figure reads, one
154
- of three; on `update_fields` a stated one replaces the field's whole.
155
- - `{ style: "decimal" }` — a plain number, as a field stating none reads.
156
- - `{ style: "currency", currency: "VND" }` — money in an ISO 4217 code.
157
- - `{ style: "unit", unit }` — a quantity: `"percent"` (10 reads "10%", never multiplied), a
158
- measured code — g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day — or a counted noun such
159
- as `kiện`. A change between two units of one dimension converts every stored figure; any other
160
- change relabels.
161
- - A currency or unit read on each row is `{ per_row: "ĐVT" }` in its place: a single select on
162
- the same row, or a lookup of one through a one-link, named by name or key, whose chosen option
163
- is that row's currency or unit — every option label an ISO 4217 code, or a unit as `unit`
164
- takes one.
165
- - **date** — `format?` (`"date"` | `"datetime"` | `"date_range"` | `"datetime_range"`), `timezone?`,
166
- `derive_from?`, `default_value?` (a date string). `derive_from: "created_at"` stamps the row's
167
- creation once, `"updated_at"` re-stamps on every update; the field is then read-only, takes no
168
- `default_value`, and holds only the `date` and `datetime` formats.
169
- - **boolean** — `default_value?` (`true` | `false`).
170
- - **select** — `options` `[{ name, color?, mark? }]`, `multi?`, `default_value?`. An option stated without a `color` gets one no sibling option holds.
171
- - **select_member** — `multi?`, `default_value?` (member ids).
172
- - **select_record_link** — `table_id`, `sync_both_ways?`, `cardinality?`, `paired_field_name?`. A
173
- link shows the row it names by the one field its table names its rows by — the table's
174
- `title_field_key`, stated when the table is made or changed, or by `records.<entity>.title` in the
175
- model; a link states no display fields of its own, and a combined name is a formula field named there.
176
- `sync_both_ways: true` on an existing one-way link makes it two-way — never add a second link for
177
- that. `cardinality: "one"` holds a single row, paired or not; a paired partner of a `"one"` side
178
- lists that side's rows as its children. Two paired sides cannot both be `"one"`. `paired_field_name`
179
- names the back-reference the target gets.
180
- - **files** — no properties.
181
- - **autonumber** — `template` (`"INV-{YEAR}-{N:4}"`; tokens `{N}`, `{N:W}` zero-padded to W,
182
- `{YEAR}`, `{YEAR:2}`, `{MONTH}`, `{DAY}`), or `prefix` + `padding` (1–20) for `PREFIX-0001`.
183
- - **any type**, at create — `confirm_before_update?`: a person confirms each later edit.
184
-
185
- `default_value` fills the field on a NEW record that states no value; existing records are never
186
- backfilled, and null clears it. A select's or a member field's default is an ARRAY even when the
187
- field holds one (`["Unread"]`, never `"Unread"`) — option names in `add_fields`, where the keys do
188
- not exist yet, and option keys (`opt_…`) in `update_fields`.
189
-
190
- `default_from` (text, number, date, boolean, select, select_member, select_record_link) copies a
191
- value onto a NEW record off the row one of its links names: `{ link_field_key, field_key,
192
- options? }`, the link of cardinality `"one"` and not itself copied, the source a field of the linked
193
- table of this field's own type, date format and notation (a link one to the same table) — a formula,
194
- a rollup, or a lookup through a one-row link (or an ordered one) read as the value it holds; several
195
- values only into a field holding several; never into a date `derive_from` stamps. It is
196
- copied when the record is made and never re-read, so a later change to the source leaves the record
197
- alone (a line's price as it was when the line was booked). A value the write states wins, then the
198
- copy, then `default_value`. A select source's `options` maps its option keys onto this field's
199
- (`update_fields`, once the keys exist); an option left unmapped is not copied. null clears it. Deleting the link a
200
- copy reads through, or the field it copies off, is refused even with `force` until the copy is
201
- cleared; the dependency report lists the copying field as `copy_link` or `copy_source` under
202
- `requires_manual_action`. Any other schema change that leaves a copy unable to stand — its source
203
- retyped, its link made to hold many — is refused, naming the copy.
204
-
205
- ### Computed fields
206
-
207
- Read-only in records. `formula` is one key holding an object; a rollup's and a lookup's properties
208
- are separate keys on the field — `rollup: {…}` and `lookup: {…}` are refused as unrecognized.
209
-
210
- formula: { expression, notation?, link? }
211
-
212
- - `{Field Name}` or `{fld_key}` names a field of the same table, stored as its key, so a rename never
213
- breaks the formula. A reference to no field, or a call to something that is no helper, is refused.
214
- - A select reads as an ARRAY of option keys: `{Status}[0]` for a single select,
215
- `includes({Tags}, "opt_…")` for a multi.
216
- - The operators, the helpers and how an empty cell reads are the `model` reference, section `formula` — a model
217
- names fields by alias where a table tool names them by name or key; the language is the same.
218
- - `notation` is how a number result reads, as a number field's; `link: true` says the result is a
219
- URL, drawn as a link that opens.
220
- - On `update_fields` a key left out keeps its stored value, and a stated `notation` replaces the
221
- stored one whole. `get_table` marks a formula that is null when every field it reads is empty.
222
-
223
- rollup — `source_field_key`, `aggregate_option: { field_key?, operation }`, `filter?`
224
-
225
- - `source_field_key` is a `select_record_link` of this table; `aggregate_option.field_key` is a
226
- field of the linked table, required by every operation but `count`, which counts linked records.
227
- - Operations by the aggregated field's type — none: `count`; any: `empty`, `filled`,
228
- `percent_empty`, `percent_filled`, `unique`, `percent_unique`; number: `sum`, `avg`, `median`,
229
- `min`, `max`, `range`; date: `earliest`, `latest`, `date_range`.
230
- - The cell's type comes from the OPERATION: `earliest` and `latest` hold a date, every other
231
- operation a number (`date_range` counts days). A "most recent linked date" is `latest` — `min` and
232
- `max` are numeric and refuse a date. A rollup takes no notation of its own: `sum` over money
233
- reads in the aggregated field's currency, over a weight in its unit, a count as a plain number
234
- and a `percent_*` in the unit `percent`.
235
- - Over a figure read in each row's own unit or currency (a notation's `per_row`), `sum`,
236
- `avg`, `median`, `min`, `max` and `range` hold only where that select is a lookup, through the
237
- link paired with `source_field_key`, of a single select on this table — the total reads in this
238
- row's option of it. A lookup of such a field stands only where its own table also looks up that select through the same one-link, and reads in that lookup: a line looks up the item's unit through its link to the item, then may look up the item's stock through the same link.
239
- - `filter` aggregates only the linked records it matches: a full group over the LINKED table's
240
- fields — `{ node_type: "group", logic, children: [{ node_type: "condition", type, field_key,
241
- operator, value }] }`, a select condition using `has_any_of` with an ARRAY value.
242
-
243
- lookup — `source_field_key`, `lookup_field_key`, `order_by?`
244
-
245
- - `lookup_field_key` is a field of the linked table. Without `order_by` the cell holds every linked
246
- record's value; with `order_by: { field_key, direction }` (`desc` latest, `asc` earliest) it holds
247
- the picked field of the single extreme record — a "latest linked X" that maintains itself.
248
- Ordered lookups sharing one `order_by` resolve to the SAME record.
249
-
250
- ### Examples
139
+ ### Changing a field
251
140
 
252
- ```
253
- { name: "Giá bán", type: "number", notation: { style: "currency", currency: "VND" } }
254
- { name: "Trọng lượng", type: "number", notation: { style: "unit", unit: "kg" } }
255
- { name: "Số lượng", type: "number", notation: { style: "unit", unit: { per_row: "ĐVT" } } }
256
- { name: "Chiết khấu", type: "number", notation: { style: "unit", unit: "percent" } }
257
- { name: "Trạng thái", type: "select", options: [{ name: "Mới" }, { name: "Xong" }] }
258
- { name: "Mã đơn", type: "autonumber", template: "SR-{YEAR}-{N:4}" }
259
- { name: "Khách hàng", type: "select_record_link", table_id: "tbl_x", sync_both_ways: true }
260
- { name: "Total", type: "formula", formula: { expression: "{Price} * {Qty}", notation: { style: "currency", currency: "VND" } } }
261
- { name: "SL đã giao", type: "rollup", source_field_key: "Giao hàng", aggregate_option: { operation: "sum", field_key: "Số lượng" } }
262
- { name: "Customer Name", type: "lookup", source_field_key: "Customer", lookup_field_key: "Name" }
263
- { name: "Latest note", type: "lookup", source_field_key: "Calls", lookup_field_key: "Note", order_by: { field_key: "At", direction: "desc" } }
141
+ Read the table with `get_table`, which answers its document exactly as `set_tables` takes it, then send `set_tables` with
142
+ the `table_id` and only what changes. The table is edited by an RFC 7396 merge patch of its document, so what the patch
143
+ leaves alone is left alone and `null` deletes the field it names. Every key a table and a field take is in
144
+ `docs path="model/tables"` and `model/field`.
145
+
146
+ A table is named by its alias wherever a call names one (`table_id`, a link's `table`, a lookup, `records.history`), and a new table in the call is named by its `ref`, which becomes its alias. The alias is kept while the table exists, archived or not: asking for one an archived table holds is refused, offering to restore that table or to choose another alias. A filter in a table document (a rollup's `filter`, `options_where`, a colour's condition, a check's lookup) takes the record tools' operators and values (`is`, `is_any_of`, `gte`, a plain date as a day). An `autonumber` field takes `next` (`"next": 232`): the number its next row gets, refused below the highest already issued.
147
+
148
+ - **A list is replaced whole**, so an option cannot be nulled. To rename, recolour, add or remove an option, restate the
149
+ field's whole `options` list, each option with its alias (`model/field/select`). A rename changes the label and every
150
+ record keeps its value; an alias left out is removed, which needs `force` while rows hold it.
151
+ - **A number field becomes an `autonumber`** when every row holds its own whole number of 1 or more: the numbers are kept
152
+ and the next row takes the one after the highest (a `next` must be above it). Otherwise the call is refused, saying how
153
+ many rows are blank, repeated or not whole, with up to 5 row ids. No other type converts to or from an autonumber; add
154
+ one as a new field, which numbers the rows already there from 1 (or from `next`) in creation order.
155
+ - **Splitting an option is a rename plus an add.** The old option keeps its alias and takes the first part's label, so its
156
+ rows and every app reference to it keep working; the second part is a new option. Rows that belong to the second part
157
+ are then moved by a filtered `update_records`.
158
+ - **A status history holds every option its status ever had** and follows them in the same write: an option added or renamed
159
+ on the status is added or renamed on the history, which a call never restates. Removing an option on the status leaves it
160
+ on the history, because the moves already written hold it. A call that changes nothing of the status's options leaves the
161
+ history alone, and options only the history holds stay. Deleting a row deletes its history rows with it, and restoring
162
+ the row restores them; they never hold a delete back.
163
+ - A link names its table by id, or by the `ref` of another table of the same call, and states its `multi`. A two-way link
164
+ states `pair` on one table, as `true` or as a free alias, and the platform makes the partner on the other table with the
165
+ inverse cardinality; a many-to-many link states both sides, since the inverse of many is not decided.
166
+ A link already holding rows is paired the same way, with its partner stated beside it in the call: the partner is filled from
167
+ the links the rows hold and no cell of the link is rewritten. `pair: null` lets a pair go, both fields staying one-way links
168
+ with their values; a field that holds one row, the partner or the link itself, refuses a pair two rows would crowd, naming the rows.
169
+ The partner may stand on a table the call does not name: `pair: "<alias>"` makes it there, or pairs the one-way link that table
170
+ already holds under that alias, and the answer lists that table among the call's. Only someone who manages that table may.
171
+ - A formula's `output_type` is inferred from its expression; state it only to override.
172
+ - **A call's removals are judged together, on what the call leaves.** A table and the links and lookups naming it, removed in one call, pass; every reader left outside the call is named in one answer, not one per retry. An app reading a field or an option that leaves is rebuilt in the same call, and the answer (a dry run too) lists each node of its tree that changed: a column, a filter, a sort key or a field of a part that only drew the field is dropped, and an option leaves every list of options it stood in. What has no meaning without it refuses the call, naming the node: an act that sets a removed option, a part left with nothing to draw, a list that held only the removed option. An app drawing a field that is retyped, or a link pointed at another table, is rebuilt too, its tree unchanged: where the field no longer lets the tree draw what it drew, the call refuses, naming the node, and a field that became a formula, rollup, lookup or autonumber leaves the app's allowlist of saves. A workflow body its author wrote is never rewritten: it is checked again against the tables as the call leaves them, and the call refuses, naming the workflow, only if it no longer checks. A table's check or workflow that reads a retyped field or a link pointed elsewhere is judged the same way and refuses, naming it, only if it no longer holds. Deleting one field through the table's field route drops no node: an app whose tree draws the field refuses it, naming the node.
173
+ - **A field something reads cannot go.** A query, a workflow, a check, an app only its owner can rebuild, or another
174
+ table's formula, rollup, lookup or copy that reads the field refuses its deletion, naming the reader. Change or remove
175
+ the reader first, or in the same call.
176
+ - **`force` consents to losing values:** deleting a field rows hold, removing an option rows hold, and a change of type
177
+ that does not convert every value.
178
+ - A money formula is proven by its numbers. `aggregate_records` the affected totals before the change and after, and
179
+ compare. A new field in a formula is tested for the value you want, never for differing from an empty cell, which reads
180
+ `""` and differs from every option.
181
+ - A check's expressions are written in the language of `workflows/table-checks`.
182
+ - `views` are stated when the table is created; afterwards they change in chat.
183
+ - A refused edit writes nothing and lists everything wrong; `dry_run` is for reading the grants it would make or deciding `force`.
184
+
185
+ A worked call: add the option `cancelled` to `stage` (the whole list restated), add a check, and consent to losing
186
+ what rows hold.
187
+
188
+ ```json5
189
+ {
190
+ "tables": [
191
+ {
192
+ "table_id": "<id of the table>",
193
+ "fields": {
194
+ "stage": {
195
+ "options": [
196
+ { "alias": "draft", "label": "Draft" },
197
+ { "alias": "confirmed", "label": "Confirmed" },
198
+ { "alias": "cancelled", "label": "Cancelled", "color": "red" }
199
+ ]
200
+ }
201
+ },
202
+ "checks": {
203
+ "returns_after_leaves": {
204
+ "on": ["create", "update"],
205
+ "when": "!isNull(record.returns)",
206
+ "fail_when": "record.returns < record.leaves",
207
+ "message": "\"Return before departure\"",
208
+ "field": "returns"
209
+ }
210
+ }
211
+ }
212
+ ],
213
+ "force": true,
214
+ "dry_run": true
215
+ }
264
216
  ```
265
217
 
266
- ### Changing a field
218
+ Splitting `queued` into waiting and underway is one call of the same shape:
219
+
220
+ ```json5
221
+ { "tables": [{ "table_id": "<id of the table>", "fields": { "stage": { "options": [
222
+ { "alias": "draft", "label": "Draft" },
223
+ { "alias": "queued", "label": "Waiting" },
224
+ { "alias": "underway", "label": "Underway", "color": "green" }
225
+ ] } } }] }
226
+ ```
267
227
 
268
- A field added by `update_table` shows in a view only when `add_to_views` names it, at the view's far
269
- right; on the CLI or in chat, `update_view` with `move_field` places it beside the columns it belongs with.
270
-
271
- An `update_fields` entry is `{ field_key, name?, description?, convert_to?, … }` with the same flat
272
- properties as `add_fields`, plus what only an update does:
273
-
274
- - A select's options change through `add_options` `[{ name, color?, mark? }]`, `update_options`
275
- `[{ key, name?, color?, mark? }]`, `remove_option_keys` and `reorder_options` (every existing
276
- option once, in order; options added in the same call follow). An option is named by its `opt_…`
277
- key (its name also resolves); a rename keeps every record's value. `mark` is the option's brand
278
- (`{ kind: "brand", name: "tiktok" }`) or kit glyph (`{ kind: "icon", name: "wrench" }`), drawn in
279
- place of its colour dot — every option of a field has one or none does, so marking sets them all
280
- in one call and `mark: null` on each clears them. `distinct_option_colors: true` re-colours a
281
- select so every option holds its own: the first option of each colour keeps it, the rest take
282
- colours no option holds.
283
- - A link's `sync_both_ways: false` disconnects the pair; a new `table_id` re-points it and clears its
284
- record data; `cardinality` is `"one"` for the child side of a parent-child pair, `"many"` for peers.
228
+ `table_id` picks the table patched; `fields` merges by alias, so only `stage` changes; `checks` adds one under its alias
229
+ (`null` removes it); `force` consents to losing values (leave it out unless rows hold a removed option); `dry_run`
230
+ checks and writes nothing, for reading the grants or deciding `force` before the call that writes.