# Tables

Source: https://mostlyright.md/docs/reference/cli/table/

These commands report what a table serves, set its refresh cadence, explicitly resync sources, freeze a version, withdraw it, rename it and retire it.

Under V4 One-Gate, a table's first succeeded run promotes it, so nothing here is what makes a table readable. `promote` records the refresh cadence and the reasoning behind it. It is idempotent on a table that is already live, and it is how you put a withdrawn table back.

None of these commands prove that a refresh worker executed. They only change or report control
state. Use `mr-data runs` and `mr-data status` to inspect execution records.

## table

```bash
mr-data table TABLE_ID [--json]
mr-data table resync TABLE_ID --request-id UUID [--json]
mr-data table rename TABLE_ID --name NAME [--json]
mr-data table archive TABLE_ID [TABLE_ID …] (--confirm DELETE | --confirm-name NAME) [--json]
```

Report what this table is serving and when it refreshes next. The schedule section says what you asked for and why, what the evidence since then settled on, and how many source updates that rests on. Put `resync`, `rename` or `archive` before the table to explicitly reread sources, change its name or retire it.

A second positional separates the bare read from an action. On its own, `mr-data table rename` returns a refusal rather than reporting on a table called `rename`.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE\|resync\|rename\|archive` | id or word | yes | The table to report on, or one of the listed actions followed by identifiers. | none |
| `TABLE …` | ids | with an act | With `resync` or `rename`, one table. With `archive`, every table being retired. The command attempts each archive target whatever the one before it answered. | none |
| `--name` | `NAME` | with `rename` | Lowercase snake_case, at most 63 characters, unique within its dataset. A recipe's `table.name` takes the same grammar, so every name here is one a recipe could rebuild. | none |
| `--request-id` | UUID | with `resync` | One caller-held idempotency key for this full source reread. Choose and retain it before submitting; repeat that exact command only to recover the same request after an ambiguous response. | none |
| `--confirm` | `DELETE` | with `archive` | The word `DELETE`, typed out. It confirms the identifiers you typed rather than a name, so it retires one table or twenty. The comparison is exact, and lowercase `delete` returns a refusal. | none |
| `--confirm-name` | `NAME` | with `archive` | One table's current name, typed out. It takes one identifier and rejects more. | none |
| `--json` | none | no | One JSON object instead of human lines. | off |

Give one confirmation form or the other, never both. Archiving hides the table from the dataset and frees its name. The bytes every run built stay where they are, downloadable and queryable by their run.

```json title="mr-data table TABLE_ID --json"
{
  "schema_version": "mostlyright-thin-client-v4-promotion.v1",
  "status": "table_reported",
  "lane": "hosted",
  "table": {
    "table_id": "3f8c2d10-6a4b-4c8e-9d1f-5b7a0c2e4d68",
    "dataset_id": "d31017e0",
    "name": "daily_city_temperatures",
    "status": "active",
    "promotion_status": "live",
    "live_version_id": "9d4e7a21-0c3b-4f56-8e2a-1b6c5d9f0a37",
    "pinned_version_id": null,
    "schedule": "0 3 * * * (UTC)",
    "schedule_record": { "cron": "0 3 * * *", "timezone": "UTC", "next_trigger_at": "2026-09-13T03:00:00Z" },
    "next_refresh_at": "2026-09-13T03:00:00Z",
    "last_refresh_at": "2026-09-12T03:00:00Z",
    "last_refresh_run_id": "7c9e1d34-8b52-4f61-9a03-2e5d6f7a8b90",
    "last_failure": null,
    "cadence": {
      "record": {},
      "state": "stable",
      "means": "The refresh schedule is settled and running.",
      "asked_for": "every 24h",
      "reason_given": "the station publishes once a day at 02:50Z",
      "settled_on": "0 3 * * * (UTC)",
      "settled_because": "…",
      "updates_seen": 41,
      "measured_gap": "every 24h",
      "quickest_source": "asos_hourly",
      "no_slower_than": "every 24h",
      "bounded_by_source": "asos_hourly"
    }
  },
  "evidence": { "source_readings": 82, "readings_that_moved": 41, "last_moved_at": "2026-09-12T02:51:00Z" }
}
```

Values are illustrative. The table's own `status` is `active` or `archived`. A separate `promotion_status` says whether it is live: `unpromoted`, `live` or `demoted`. `schedule` flattens the wire's `{cron, timezone, next_trigger_at}` into one phrase, and `schedule_record` carries the object whole beside it. `cadence.state` is `learning`, `stable`, `dormant` or `locked`, and `means` is the sentence for it. The command never prints the token on its own. `cadence` is `null` on a table that carries none.

`evidence` comes from a separate route. When that route cannot answer, the command leaves the block out rather than failing. `source_readings` counts one reading per source per refresh, not one per refresh. `counted_over` appears when the page is full, because the listing is also the retention.

When the last refresh failed, `last_failure` carries the whole `{failure_code, failure_detail, failed_stage}`. The payload's `note` says what that means: live did not advance, so people are reading the version from before it.

```json title="mr-data table rename TABLE_ID --name new_name --json"
{
  "schema_version": "mostlyright-thin-client-v4-table-lifecycle.v1",
  "status": "table_renamed",
  "lane": "hosted",
  "table": { "…": "…" },
  "name": "new_name",
  "previous_name": "old_name",
  "retried_after_conflict": false,
  "note": "The name old_name is released, and another table in this dataset may take it."
}
```

The released-name sentence appears only when the rename gave a name up. Renaming a table to the name it already has changes nothing and leaves the sentence out.

```json title="mr-data table archive A B --confirm DELETE --json"
{
  "schema_version": "mostlyright-thin-client-v4-table-batch.v1",
  "status": "tables_partly_archived",
  "lane": "hosted",
  "counts": { "requested": 2, "archived": 1, "refused": 1, "unreachable": 0 },
  "outcomes": { "table 1": "…", "table 2": "…" },
  "tables": [
    { "table_id": "3f8c2d10-6a4b-4c8e-9d1f-5b7a0c2e4d68", "outcome": "archived", "name": "old_table", "code": null, "said": "…", "table": {} },
    { "table_id": "tbl_02J…", "outcome": "refused", "name": null, "code": "THIN_STUDIO_TABLE_LIVE", "said": "…", "table": null }
  ],
  "note": "…",
  "confirmed_by": "--confirm DELETE"
}
```

`status` is `tables_archived`, `tables_partly_archived` or `tables_not_archived`, one word each for all of them, some of them and none of them. The command attempts each table whatever the one before it returned. `outcome` reads `refused` when Studio returned no, and `unreachable` when no answer arrived at all. A missing response can follow a committed mutation, so read the table state before retrying. The command exits `1` when `refused + unreachable` is non-zero.

Errors on `archive`: `THIN_STUDIO_TABLE_LIVE` (withdraw it with `demote` first), `THIN_STUDIO_TABLE_BUSY` (a run against it is in flight), `THIN_STUDIO_TABLE_ARCHIVED`, `THIN_CONFIRMATION_MISMATCH`. On `rename`: `THIN_REQUEST_INVALID` for a name outside the grammar or more than one identifier, `THIN_DATASET_VERSION_CONFLICT` when the dataset moved twice under the retry.

## resync

```bash
mr-data table resync TABLE_ID --request-id UUID [--json]
```

Resync is the explicit whole-source operation. It is for a corrected recipe, another snapshot, an unwindowed source, or known historical repair. It is not a way to make a normal refresh "try harder": normal and scheduled refreshes remain incremental and return Studio's public `RESYNC_REQUIRED` refusal before acquisition when there is no normal-refresh action or bounded materializer.

The response carries the run that Studio admitted, the caller-held `request_id` that makes a retry idempotent, and `idempotent_replay: true` only when Studio returned the run already admitted for that same key. A new request id means a deliberate new resync. A resync uses one progressive full build, with any required confirmation before acquisition. Its inspection checkpoint continues automatically without a separate sample or second full run. Existing legacy sample/full pairs remain readable and approvable. The resync rereads every source, rather than carrying an incremental refresh action plan.

```bash title="report, then retire"
mr-data table "$TABLE_ID" --json | jq '{serving: .table.live_version_id, next: .table.next_refresh_at}'
mr-data demote "$TABLE_ID"
mr-data table archive "$TABLE_ID" --confirm DELETE
```

## The cadence grammar

`promote --cadence` and `reschedule --cadence` take one of four forms:

| Form | Example | Meaning |
| --- | --- | --- |
| A five-field cron expression | `0 3 * * *` | Refresh at these times. |
| An interval | `every 6h` | Refresh this often. The units are `m`, `h` and `d`. The count is 1 to 6 digits. |
| An anchored interval | `every 30m from 2026-09-03T12:20:00Z` | The same, phased from that moment. The anchor is `YYYY-MM-DDTHH:MM:SSZ`, optionally with 1 to 9 fractional-second digits, and a literal uppercase `T` and `Z`. |
| The word `source` | `source` | Let Studio choose from what the source does. `promote` only. `reschedule` refuses it, because it asks for the schedule to move to rather than a starting point. |

`every`, `from` and the unit letter are case-insensitive. The anchor's `T` and `Z` are not, because the contract refuses a timestamp with a lowercase `z`.

Whatever you give is a starting point. Studio keeps moving the schedule to match what it sees the source do, unless you freeze it with `reschedule --lock`. On `promote`, every form except `source` requires `--why`. A reason offered beside `source` returns a refusal instead of being ignored. On `reschedule` it is never required, only bounded. `--why` is at most 2000 characters.

## promote

```bash
mr-data promote TABLE_ID [--cadence CADENCE] [--why TEXT] [--no-activity] [--json]
```

Record how often this table refreshes and the reasoning behind it. A table's first succeeded run goes live on its own, so this command is not what makes it readable. It is idempotent on a table that is already live, and it is how you put a withdrawn table back.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE` | id | yes | The table, by the identifier `mr-data recipe` printed when it registered one. | none |
| `--cadence` | cadence | no | The refresh schedule to start from. See [The cadence grammar](#the-cadence-grammar). | none |
| `--why` | `TEXT` | with a stated cadence | Why this schedule fits the source, in a sentence or two. It sits beside the table for the next person to read. | none |
| `--no-activity` | none | no | Accepted, and has no effect from 0.27.1: this command tells the dataset page nothing. | off |
| `--json` | none | no | One JSON object instead of human lines. | off |

```json title="mr-data promote TABLE_ID --cadence 'every 24h' --why '…' --json"
{
  "schema_version": "mostlyright-thin-client-v4-promotion.v1",
  "status": "table_promoted",
  "lane": "hosted",
  "table": { "…": "the same projection mr-data table prints" },
  "note": "Studio accepted the publication request and reports the resulting table state below. This response alone does not prove idle-period catch-up or scheduled refresh executed."
}
```

Errors:

| Code | When |
| --- | --- |
| `THIN_STUDIO_NOTHING_TO_PROMOTE` | There is no succeeded run to make live yet. Start one with `mr-data run --recipe … --digest … --full`, and `mr-data checks` tells you whether it passed. |
| `THIN_STUDIO_TABLE_ARCHIVED` | This table is retired, so nothing can make it live. Nothing built was deleted. To publish this work, register a recipe naming a new table in the same dataset and promote that one. |
| `THIN_REQUEST_INVALID` | A cadence outside the grammar. A `--why` beside `--cadence source`. A `--why` over 2000 characters. A stated cadence with no `--why`. |

```bash
mr-data promote "$TABLE_ID" --cadence "every 24h" --why "the station publishes once a day at 02:50Z"
```

## reschedule

```bash
mr-data reschedule TABLE_ID --cadence CADENCE [--why TEXT] [--lock | --unlock] [--json]
```

Change the refresh schedule for this table. This builds nothing and spends nothing. The next refresh happens at the new time.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE` | id | yes | The table. | none |
| `--cadence` | cadence | yes | The refresh schedule to start from. See [The cadence grammar](#the-cadence-grammar). | none |
| `--why` | `TEXT` | no | Why this schedule fits the source. Never required here, unlike on `promote`. | none |
| `--lock` | none | no | Hold this schedule and stop adjusting it from what the source does. | off |
| `--unlock` | none | no | Let the schedule follow what the source does again. | off |
| `--json` | none | no | One JSON object instead of human lines. | off |

`--lock` and `--unlock` are mutually exclusive.

```json title="mr-data reschedule TABLE_ID --cadence '0 3 * * *' --why '…' --json"
{
  "schema_version": "mostlyright-thin-client-v4-promotion.v1",
  "status": "table_rescheduled",
  "lane": "hosted",
  "table": { "…": "the same projection mr-data table prints" }
}
```

After a `--lock`, `table.cadence.state` reads `locked` and `means` reads "You fixed this schedule, so it is left alone."

Errors:

| Code | When |
| --- | --- |
| `THIN_REQUEST_INVALID` | `--cadence source`, which asks Studio to pick a starting point and belongs on a table's first publication. A cadence outside the grammar. An anchor whose digits name no moment. An interval count of zero. A `--why` over 2000 characters. |
| `THIN_STUDIO_*` | The table's own refusals, carried through under Studio's codes. That includes the version conflict when somebody else moved the schedule between the read and the write. |

`promote`'s two other `THIN_REQUEST_INVALID` causes cannot arise here. A stated cadence needs no `--why`, and the gate refuses `source` before it reads the reason.

```bash
mr-data reschedule "$TABLE_ID" --cadence "0 3 * * *" --why "the publisher posts overnight" --lock
```

## pin

```bash
mr-data pin TABLE_ID --version VERSION_ID [--json]
```

Freeze what people are reading, at a version you name. Use it when a source starts publishing something you do not want served yet. Use it also to hold a version steady while you check something downstream against it.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE` | id | yes | The table. | none |
| `--version` | `VERSION_ID` | yes | The version to freeze at. A succeeded run's record carries its version identifier, and `mr-data status RUN_ID` prints it. | none |
| `--json` | none | no | One JSON object instead of human lines. | off |

```json title="mr-data pin TABLE_ID --version 9d4e7a21-0c3b-4f56-8e2a-1b6c5d9f0a37 --json"
{
  "schema_version": "mostlyright-thin-client-v4-promotion.v1",
  "status": "table_pinned",
  "lane": "hosted",
  "table": { "pinned_version_id": "9d4e7a21-0c3b-4f56-8e2a-1b6c5d9f0a37", "live_version_id": "9d4e7a21-0c3b-4f56-8e2a-1b6c5d9f0a37", "…": "…" }
}
```

On a pinned table, `pinned_version_id` is the version people are reading, and a bare `mr-data parts` lists it.

Errors: `THIN_REQUEST_INVALID` when `--version` is missing, and the refusal says where to find one. `THIN_NOT_FOUND` for a version this table does not hold.

```bash
mr-data pin "$TABLE_ID" --version "$VERSION_ID" --json
```

## unpin

```bash
mr-data unpin TABLE_ID [--json]
```

Remove the pin and report the live-pointer state that comes back. It does not prove that a refresh ran or that a newer version exists. It lifts the freeze, and the schedule decides what people read next.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE` | id | yes | The table. | none |
| `--json` | none | no | One JSON object instead of human lines. | off |

```json title="mr-data unpin TABLE_ID --json"
{
  "schema_version": "mostlyright-thin-client-v4-promotion.v1",
  "status": "table_unpinned",
  "lane": "hosted",
  "table": { "pinned_version_id": null, "live_version_id": "tv_02J…", "…": "…" }
}
```

Errors: `THIN_STUDIO_*` for a table with no pin to remove.

```bash
mr-data unpin "$TABLE_ID" --json
```

## demote

```bash
mr-data demote TABLE_ID [TABLE_ID …] [--json]
```

Withdraw the live pointer from each table you name and report the schedule state each one returns. Built versions stay readable and stay downloadable by their run. Serving stops.

Naming several tables withdraws them one after another. The command attempts every one whatever the one before it answered, prints each on its own line, and exits non-zero if any is still live.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE …` | ids | yes | One table or several. | none |
| `--json` | none | no | One JSON object instead of human lines. | off |

```json title="mr-data demote A B --json"
{
  "schema_version": "mostlyright-thin-client-v4-table-batch.v1",
  "status": "tables_demoted",
  "lane": "hosted",
  "counts": { "requested": 2, "demoted": 2, "refused": 0, "unreachable": 0 },
  "outcomes": { "table 1": "…", "table 2": "…" },
  "tables": [{ "table_id": "3f8c2d10-6a4b-4c8e-9d1f-5b7a0c2e4d68", "outcome": "demoted", "name": "…", "code": null, "said": "…", "table": {} }],
  "note": "…"
}
```

`status` is `tables_demoted`, `tables_partly_demoted` or `tables_not_demoted`. The command exits `1` when `refused + unreachable` is non-zero.

Errors per table: `THIN_STUDIO_TABLE_BUSY` when a run against this table is in flight. The live pointer has not moved yet, so follow the run with `mr-data watch`. `THIN_STUDIO_TABLE_ARCHIVED` when the table is retired, so there is no live pointer to withdraw.

```bash
mr-data demote "$TABLE_A" "$TABLE_B" --json | jq '.counts'
```

## See also

- [CLI reference](/docs/reference/cli/): conventions, exit codes, environment variables
- [Reading a table](/docs/reference/cli/read/): `parts` lists what the live or pinned version holds
- [Runs](/docs/reference/cli/run/): the runs that build the versions these commands point at
