@carllee1983/dbcli 1.58.0 → 2.0.0

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.
@@ -43,6 +43,18 @@ condition, first `query` / `export` the target rows' primary keys, then run one
43
43
  `update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
44
44
  takes a full JSON filter and is exempt.)
45
45
 
46
+ **Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
47
+ tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
48
+ run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
49
+ **Statements that are not limited to specific rows are refused outright when nobody can
50
+ answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
51
+ the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
52
+ no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
53
+ `reason=unparseable` or `reason=non_unique_where`, and **nothing reaches the database**.
54
+ **No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
55
+ the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
56
+ unattended route at all; escalate to a human.
57
+
46
58
  > `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
47
59
  > `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
48
60
  > audit-recent context or to diagnose a connection problem.
@@ -334,9 +334,43 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
334
334
  dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
335
335
  ```
336
336
 
337
- **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
337
+ **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--yes`, `--recovery`
338
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
339
339
 
340
+ #### Write confirmation gate (2.0.0)
341
+
342
+ A SQL write passes through a two-tier gate before the connection is opened. The gate is
343
+ separate from the permission axis: permission says what the connection may do, the gate
344
+ says whether this particular statement may run right now.
345
+
346
+ | Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
347
+ | :--- | :--- | :--- | :--- |
348
+ | One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
349
+ | Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
350
+
351
+ A refusal message names a machine-readable reason — `reason=no_where`,
352
+ `reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
353
+ connection failure or a permission denial.
354
+
355
+ **Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
356
+
357
+ ```bash
358
+ dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
359
+ dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
360
+ dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
361
+ dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
362
+ ```
363
+
364
+ This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
365
+ `WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
366
+ statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
367
+ and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
368
+ whether they are possible at all, and the typed confirmation must come from a person.
369
+
370
+ Every tier-two evaluation is written to the audit log with
371
+ `metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
372
+ `metadata.write_gate_reason`.
373
+
340
374
  > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
375
  > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
376
  > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
@@ -955,6 +989,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
955
989
  > target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
956
990
  > (MongoDB `--where` accepts a full JSON filter and is exempt.)
957
991
 
992
+ > **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
993
+ > and no unique index selects an unknown number of rows, so it is treated the same as a
994
+ > raw statement with no `WHERE`: the target table name must be typed at an interactive
995
+ > terminal, and a non-interactive run is refused with exit `1` and
996
+ > `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
997
+ > the ordinary confirmation only. Select the primary keys first and write one row at a
998
+ > time, or run it where a person can confirm it.
999
+
958
1000
  ### delete
959
1001
 
960
1002
  Delete data from a table.
@@ -43,6 +43,18 @@ condition, first `query` / `export` the target rows' primary keys, then run one
43
43
  `update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
44
44
  takes a full JSON filter and is exempt.)
45
45
 
46
+ **Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
47
+ tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
48
+ run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
49
+ **Statements that are not limited to specific rows are refused outright when nobody can
50
+ answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
51
+ the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
52
+ no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
53
+ `reason=unparseable` or `reason=non_unique_where`, and **nothing reaches the database**.
54
+ **No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
55
+ the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
56
+ unattended route at all; escalate to a human.
57
+
46
58
  > `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
47
59
  > `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
48
60
  > audit-recent context or to diagnose a connection problem.
@@ -334,9 +334,43 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
334
334
  dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
335
335
  ```
336
336
 
337
- **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
337
+ **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--yes`, `--recovery`
338
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
339
339
 
340
+ #### Write confirmation gate (2.0.0)
341
+
342
+ A SQL write passes through a two-tier gate before the connection is opened. The gate is
343
+ separate from the permission axis: permission says what the connection may do, the gate
344
+ says whether this particular statement may run right now.
345
+
346
+ | Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
347
+ | :--- | :--- | :--- | :--- |
348
+ | One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
349
+ | Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
350
+
351
+ A refusal message names a machine-readable reason — `reason=no_where`,
352
+ `reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
353
+ connection failure or a permission denial.
354
+
355
+ **Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
356
+
357
+ ```bash
358
+ dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
359
+ dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
360
+ dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
361
+ dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
362
+ ```
363
+
364
+ This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
365
+ `WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
366
+ statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
367
+ and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
368
+ whether they are possible at all, and the typed confirmation must come from a person.
369
+
370
+ Every tier-two evaluation is written to the audit log with
371
+ `metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
372
+ `metadata.write_gate_reason`.
373
+
340
374
  > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
375
  > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
376
  > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
@@ -955,6 +989,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
955
989
  > target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
956
990
  > (MongoDB `--where` accepts a full JSON filter and is exempt.)
957
991
 
992
+ > **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
993
+ > and no unique index selects an unknown number of rows, so it is treated the same as a
994
+ > raw statement with no `WHERE`: the target table name must be typed at an interactive
995
+ > terminal, and a non-interactive run is refused with exit `1` and
996
+ > `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
997
+ > the ordinary confirmation only. Select the primary keys first and write one row at a
998
+ > time, or run it where a person can confirm it.
999
+
958
1000
  ### delete
959
1001
 
960
1002
  Delete data from a table.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to dbcli are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.0.0] - 2026-08-14 - A write nobody can confirm does not run
9
+
10
+ ### Changed
11
+
12
+ - **BREAKING: a statement that is not limited to particular rows is refused when nobody is watching.** `dbcli query "UPDATE users SET banned = 1"` used to execute against any read-write connection without asking anything, and the caller most likely to produce an unqualified `UPDATE` is the agent this product exists to serve. Raw SQL now passes through a two-tier gate before the connection is opened. Tier one is any write — an `INSERT`, an `UPDATE` or `DELETE` that has a `WHERE` or `LIMIT`, a `CREATE`, an `ALTER` — and behaves as it always has for a non-interactive caller; at a terminal it shows what dbcli understood the statement to do and asks, which `--yes` skips. Tier two is `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, several statements in one string — one statement to a classifier reading the leading keyword, two to a driver — and any statement the SQL parser cannot read: at a terminal the operator types the target table name, and **no flag skips it** — not `--yes`, not `--force`. Away from a terminal, or under `--format json`, tier two is refused: exit `1`, a `reason=` a caller can branch on (`no_where`, `ddl_destruction`, `unparseable`, `multiple_statements`), and the statement never sent, because the gate runs before the adapter is built rather than after. A parse failure resolves to tier two rather than tier one; the cost of being wrong is a needlessly typed table name against a needlessly emptied table. The reasoning, the alternatives, and the condition that would falsify it are in `docs/adr/0010-unattended-callers-are-refused-full-table-writes.md` (#70).
13
+
14
+ - **BREAKING: `dbcli update` / `dbcli delete` refuse a `--where` that matches on nothing unique.** Their `WHERE` is mandatory, so "no `WHERE`" cannot happen — but `--where "status=active"` reads like a filter and writes like a full-table statement. When the conditions cover neither the primary key nor any unique index, the same tier-two treatment applies: type the table name, or be refused with `reason=non_unique_where`. The schema needed to tell the two apart is already in hand at that point, so this costs no extra round trip. `--force` is unaffected in what it always did — skip the ordinary confirmation — and does not open this gate (#70).
15
+
16
+ - **BREAKING: `admin` permission no longer means "everything runs".** Permission and the gate are separate axes now: permission says what the connection may do, the gate says whether this statement may run right now. An `admin` connection running `DROP TABLE users` from a script is refused, because `DROP` and `TRUNCATE` have no clause to add and therefore no unattended route at all. Whether they are possible in an environment remains a `permission` decision that lives in version control; whether one happens today is a decision a person makes at a terminal (#70).
17
+
18
+ - **The permission check for raw SQL now runs before the connection is opened.** It ran inside `QueryExecutor`, after `connect()`, so a refusal cost a round trip and arrived after the gate had already asked its question. The command layer now calls the same `enforcePermission` with the same real statement before either — a connection that may not run this at all is told so rather than asked to confirm something it was never going to be allowed to do. The executor still checks; this only moves the verdict earlier (#70).
19
+
20
+ ### Added
21
+
22
+ - **`dbcli query --yes`** skips the tier-one confirmation, so a long sequence of routine writes does not become a sequence of keypresses. It has no effect on tier two by design: a flag that could be set once and forgotten is exactly what the escape route for a full-table write must not be (#70).
23
+
24
+ - **Every tier-two evaluation is written to the audit log — allowed, declined and refused alike** — with `metadata.write_gate_outcome` and `metadata.write_gate_reason`. Deliberately unconditional: a log that kept only the refusals could not tell "nobody writes like that" apart from "everybody found a way around it". In six months, whether this gate prevented anything is a query rather than an impression, and if tier two turns out to be almost never reached, the criterion is wrong rather than the gate unnecessary (#70).
25
+
26
+ ### Migration
27
+
28
+ Automation that performs unqualified full-table writes stops working. Three shapes are affected, and each has a fixed remedy:
29
+
30
+ | Invocation | Now | Remedy |
31
+ | :--- | :--- | :--- |
32
+ | `dbcli query "UPDATE t SET c = v"` | exit 1, `reason=no_where` | `dbcli query "UPDATE t SET c = v WHERE 1=1"`, or add a `LIMIT` |
33
+ | `dbcli query "DELETE FROM t"` | exit 1, `reason=no_where` | `dbcli query "DELETE FROM t WHERE 1=1"`, or add a `LIMIT` |
34
+ | `dbcli query "DROP TABLE t"` / `TRUNCATE` | exit 1, `reason=ddl_destruction` | run it at a terminal, or apply the schema change through a reviewed migration |
35
+ | `dbcli update t --where "status=x" …` | exit 1, `reason=non_unique_where` | select the primary keys first, then one write per key — or run it where a person can confirm |
36
+
37
+ `WHERE 1=1` is the supported way to say "yes, every row". It is intentionally not a flag: appended to a statement that already has a `WHERE` it is a syntax error, so it cannot be added blanket-style to a script and forgotten. There is no environment variable that restores the old behaviour — a flag that makes the same version behave differently on different machines makes every later bug report ambiguous, and never gets removed.
38
+
8
39
  ## [1.58.0] - 2026-08-14 - A write that did not happen stops reporting success
9
40
 
10
41
  ### Changed
package/assets/SKILL.md CHANGED
@@ -43,6 +43,18 @@ condition, first `query` / `export` the target rows' primary keys, then run one
43
43
  `update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
44
44
  takes a full JSON filter and is exempt.)
45
45
 
46
+ **Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
47
+ tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
48
+ run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
49
+ **Statements that are not limited to specific rows are refused outright when nobody can
50
+ answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
51
+ the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
52
+ no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
53
+ `reason=unparseable` or `reason=non_unique_where`, and **nothing reaches the database**.
54
+ **No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
55
+ the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
56
+ unattended route at all; escalate to a human.
57
+
46
58
  > `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
47
59
  > `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
48
60
  > audit-recent context or to diagnose a connection problem.
@@ -36,6 +36,17 @@ legacy 單檔 `.dbcli`。若要防護同一 OS 使用者的惡意 process,host
36
36
  `update` / `delete --where "id=<pk>"`(逐一等式)— 或升級交給人類處理。(MongoDB 的
37
37
  `--where` 接受完整 JSON filter,不受此限。)
38
38
 
39
+ **寫入閘門(2.0.0)— 會直接拒絕你的那條規則。** 所有寫入都會分成兩級。一般寫入
40
+ (`INSERT`、帶 `WHERE` 的 `UPDATE` / `DELETE`、`CREATE`、`ALTER`)在無人看管下照跑,
41
+ 與過去相同;`--yes` 用來跳過人類在終端機看到的提問。**沒有限定要動哪些列的語句,在沒有
42
+ 人能回答提問時會直接被拒絕** — 沒有 `WHERE` 的 `UPDATE` / `DELETE`、`DROP`、`TRUNCATE`、
43
+ SQL parser 讀不懂的語句、一個字串裡塞了多句語句,以及 `update` / `delete --where` 沒有命中主鍵或唯一索引的情況。
44
+ 行程以 `1` 結束,訊息點名 `reason=no_where`、`reason=ddl_destruction`、
45
+ `reason=unparseable` 或 `reason=non_unique_where`,而且**什麼都不會送到資料庫**。
46
+ **沒有任何旗標可以繞過** — `--yes` 不行,`--force` 也不行。真的要寫全表,就把意圖寫進
47
+ SQL 本身:補上 `WHERE 1=1` 或 `LIMIT`。`DROP` / `TRUNCATE` 完全沒有無人看管的路徑,請
48
+ 升級交給人類處理。
49
+
39
50
  > `report` 與 `guide` 已內嵌 `inspect` 快照 — **不需要**先跑 `dbcli inspect`。只有在需要 audit-recent 脈絡或診斷連線問題時,才手動跑 `dbcli inspect --for-agent`。
40
51
 
41
52
  **依任務路由:**
@@ -334,9 +334,43 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
334
334
  dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
335
335
  ```
336
336
 
337
- **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
337
+ **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--yes`, `--recovery`
338
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
339
339
 
340
+ #### Write confirmation gate (2.0.0)
341
+
342
+ A SQL write passes through a two-tier gate before the connection is opened. The gate is
343
+ separate from the permission axis: permission says what the connection may do, the gate
344
+ says whether this particular statement may run right now.
345
+
346
+ | Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
347
+ | :--- | :--- | :--- | :--- |
348
+ | One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
349
+ | Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
350
+
351
+ A refusal message names a machine-readable reason — `reason=no_where`,
352
+ `reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
353
+ connection failure or a permission denial.
354
+
355
+ **Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
356
+
357
+ ```bash
358
+ dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
359
+ dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
360
+ dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
361
+ dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
362
+ ```
363
+
364
+ This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
365
+ `WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
366
+ statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
367
+ and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
368
+ whether they are possible at all, and the typed confirmation must come from a person.
369
+
370
+ Every tier-two evaluation is written to the audit log with
371
+ `metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
372
+ `metadata.write_gate_reason`.
373
+
340
374
  > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
375
  > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
376
  > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
@@ -955,6 +989,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
955
989
  > target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
956
990
  > (MongoDB `--where` accepts a full JSON filter and is exempt.)
957
991
 
992
+ > **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
993
+ > and no unique index selects an unknown number of rows, so it is treated the same as a
994
+ > raw statement with no `WHERE`: the target table name must be typed at an interactive
995
+ > terminal, and a non-interactive run is refused with exit `1` and
996
+ > `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
997
+ > the ordinary confirmation only. Select the primary keys first and write one row at a
998
+ > time, or run it where a person can confirm it.
999
+
958
1000
  ### delete
959
1001
 
960
1002
  Delete data from a table.