ebman 0.42.0

k9s-style TUI for AWS Elastic Beanstalk
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
# Protection levels for operator tools

**Status:** design note. Parts have since shipped — the converged write
gate, MCP tool annotations, the elicitation detection and refusal
auditing — and the sections below mark which. The levels and the
attestation are still unbuilt. Written 2026-09-09 for
`ebman`, but the rules in [Principles](#principles) are meant to hold for
any tool that lets a person — or an agent acting for them — change
production. `pgman` is the second consumer and the reason this is
written generally.

**Revised after review, 2026-09-09.** Two reviewers read it against the
code. Corrections worth knowing if you read the first draft: ebman has
**two** write gates rather than one, so the cost estimate was wrong;
pgman is a **migration**, not a greenfield consumer, and already ships a
safety engine of a different shape; the decision model needed
**obligations**; `ask` is a transport problem before a policy one; and
the config example both violated Principle 5 and used syntax the parser
cannot read. Each is marked in place rather than quietly fixed, because
the wrong version is the one people will have skimmed.

## The problem

An operator tool that can only refuse everything or permit everything
forces a bad choice. Turn protection off and it protects nothing; leave
it on and people work around it. Agents sharpen this: they act faster
than a human reviews, they retry, and given an opaque refusal they will
look for another route to the same effect.

`ebman` today has most of the raw material — a per-env `read_only` pin,
account pins, a session `--read-only`, a cross-process deploy freeze,
confirm modals (including a type-the-environment-name gate on DLQ
purge), a 5-second undo window, an audit line per dispatch tagged with
provenance, and MCP writes off unless `--allow-writes`. What it does not have is a way to
say *how careful to be* in one place, or to answer an agent's "may I?"
in a form the agent can act on.

## Principles

These are the transferable part. They are stated as rules because each
one was learned from something that went wrong somewhere.

### 1. The boundary is not yours. Say so.

IAM is the security boundary for `ebman`; database roles are for
`pgman`. Anyone holding credentials can bypass the tool entirely with
the vendor CLI. A protection level is a guardrail against **mistakes**,
not an adversary, and the documentation must say that plainly.

A guardrail described as a control gets trusted for the wrong things,
and the failure is silent until the day it matters. Claim exactly the
protection you provide.

### 2. Every escape hatch becomes the habit.

If `--force` exists and works, `--force` is what people type — and what
they put in the runbook, and what the agent learns to send. This is not
hypothetical: this repo's own `CLAUDE.md` makes "widening an allowlist
to make a guard go quiet" a stop condition precisely because it is the
cheapest wrong path and it always works.

So: an escape must be **non-defaultable** — impossible to pre-commit to
a script or reduce to muscle memory — and must leave a record. A `--yes`
that skips everything is the wrong shape.

Stated that way because the obvious example does not travel.
`ebman`'s type-the-environment-name-to-purge works because a human is at
a TUI; a headless CI principal cannot type anything interactively, and
pgman's equivalent friction is a different kind entirely — wrapping the
statement in a transaction the operator must then commit, which is
friction *after* dispatch rather than before.

### 3. Refusals must be machine-readable, and say whether to give up.

A refusal in prose tells an agent nothing except that it failed. It
cannot tell "you will never be allowed this" from "a human can unlock
it" from "try a different resource" — so it retries, or it finds a side
door, or it stops when it should have asked.

Emit a document, not a sentence. The two fields that change behaviour
most are **`retryable`** and **`remedy`**.

### 4. Three outcomes, not two.

`allow` / `deny` forces every uncertain case to one extreme. The useful
middle is **`ask`**: the agent is not blocked and the human is not
bypassed. `sudo`, `polkit` and Claude Code's own permission model all
settled on this shape; a tool that skips it will grow it later under a
worse name. pgman reached the same tri-state independently
(`Guard { Allow, Confirm, Block }`), which is the best evidence this
principle is discovered rather than invented.

**But `ask` is a transport question before it is a policy one**, and a
ladder defined in terms of asking is worthless if the primary agent
transport cannot express it. See the section below before designing
levels — this is the sequencing mistake most likely to waste the work.

### 5. A principal cannot raise its own level.

The ceiling is set by *who is asking*, established outside the request —
a server flag, a config file, a credential — never by a parameter the
caller supplies. `ebman`'s `--allow-writes` already has this property
because it is a server flag; keep it when the model gets richer.

Corollary: identify principals distinctly enough to be useful. "An
agent" is too coarse; `mcp:claude-code` is a principal, and a human at
the TUI is a different one — but see the config section, because an
identity the caller supplies is a label, not an authentication.

The harder corollary: **the ceiling must be enforced at a layer the
request content cannot reach, and each tool must enumerate its own
in-band escape routes.** This is domain work the engine cannot do for
you. ebman's split helps because the client sends structured tool calls;
pgman's does not, because a request there is free-text SQL and
`SET transaction_read_only = off` is a perfectly ordinary statement — it
needs a dedicated check (`attempts_read_only_escape`) built on its SQL
lexer to catch a level being raised in-band.

### 6. Presets must be printable.

Named levels are the friendly surface. They are only trustworthy if the
tool can print exactly what a level expands to. An opaque preset becomes
the thing nobody can reason about — the same way the hand-maintained
`WRITE_COMMANDS` and `CONFIRM_STATE` lists in this repo needed guards
before anyone could rely on them.

`<tool> safety explain` should print the full expansion for the current
principal, with the reason each rule applies.

### 7. Encode practice as preconditions, not prose.

"Don't deploy on a Friday" in a runbook is a wish. As a precondition it
is enforceable, inspectable, and overridable with a record.

This is the weakest principle here and should be treated as a direction
rather than a rule: its only concrete instance is deferred out of v1
below, and it quietly conflates two different things. *"May I?"* is
permission, decided from policy. *"Should I, yet?"* is advice, decided
from live state — is staging green, has the canary soaked. They want
different inputs and probably different machinery, and running them
together is exactly the seam where domain logic leaks into a supposedly
neutral engine. Keep them separable.

### 8. Every decision is auditable, including the allows.

Recording only refusals tells you what was blocked and nothing about
what happened. `ebman` already writes an audit line per dispatch tagged
with provenance (`via=mcp client=<name>`); the policy decision belongs
on the same line.

**Status: implemented, both halves.** Allows were already covered by the
`dispatched`/`completed` pair. Denials were not covered at all — a
refused write never dispatches, so nothing was written and repeated
attempts against a pinned environment were indistinguishable from
nobody trying. Refusals now emit `stage=refused` from all four
enforcement funnels, carrying `rule=` (a stable token, because the
rendered wording differs per surface by design) and `remedy=` (the
specific config key, per Principle 3 — a refusal that says "a safety
pin" leaves an agent to guess, and the guesses are retry, try a
neighbouring env, or go edit the config).

What is still missing is the *level* on the line: today it records which
rule refused, not which rung the principal was on. That arrives with the
levels themselves.


## The ask and the attestation

**Added 2026-09-18**, after a live incident made stages 4-5 concrete: an
MCP client wanted to delete one dead-lettered message and the only way
to grant it was `--allow-writes`, which also grants terminate across
Prod.

**Revised the same day after an adversarial review**, which found the
first draft's central justification over-generalised from that one
incident. The corrections are marked in place, because the wrong version
is the one people skim.

The requirement, in the maintainer's words: *"I'd like the agent to be
able to ask if it's ok to change/delete things, but also to unlock the
ability themselves if they feel they've been given that permission."*

### The premise the first draft got wrong

The first draft argued: ebman is not the security boundary, IAM is; an
agent holding credentials can bypass ebman with `aws sqs` anyway;
therefore the gate is for deliberateness rather than prevention, and an
agent proceeding *on the record* beats one silently shelling out.

**That is true of the deployment it was written from, and false of the
deployments that care most.** An agent configured with
`mcp__ebman__*` allowed and `Bash` denied — an ordinary setup, arguably
the recommended one — has no `aws` fallback. Nor does a shell-less
desktop client, nor an agent talking to a containerised server running
under a role the agent's own context does not hold. For those
principals **ebman's credentials are a capability with no other route,
and ebman IS the effective boundary**, whatever IAM is for the human.

So the IAM argument is demoted from justification to what it actually
is: **a deployment-dependent fact, and a reason a particular operator
might choose to accept attestations.** It cannot carry the design.

### Attestation is an operator opt-in, default off

Whether an attestation satisfies an `ask` is set **outside the request**:

```toml
# Default: absent, meaning attestations are not accepted. An `ask` that
# cannot reach a human then degrades to deny.
safety.principals.mcp.attestation = "accepted"
```

This is what makes Principle 5 hold **by construction** rather than by
argument. The hard corollary — *the ceiling must be enforced at a layer
the request content cannot reach* — is satisfied because an attestation
is request content and the key that gives it force is not.

An operator in a shared-credentials deployment turns it on because the
alternative is the agent using `aws sqs` unaudited. An operator who has
deliberately denied their agent a shell leaves it off, and gets the
containment they configured for.

### What an attestation can never touch

The first draft offered a rule — *may raise a DEFAULT, never override a
DECISION* — and the review was right that it does not survive contact
with the levels design. If the `ask` comes from a level an operator
chose, then it is a decision and attestation answers nothing; if it does
not, then attestation routinely softens levels operators explicitly
chose. The vocabulary was a rationalisation, not a predicate.

Replaced with the list it was gesturing at. An attestation has no effect
on any of these, ever:

| Control | Why |
|---|---|
| `--allow-writes` absent | the tool is not advertised; there is nothing to attest to |
| `safety.principals.*.attestation` absent | the opt-in above |
| `safety.envs.*.read_only` / `safety.accounts.*.read_only` | an operator named this resource |
| deploy freeze / `:incident` | the incident lever, and the most likely moment for an agent to be wrong |
| `safety_parse_errors` non-empty | the config is unreadable; a corrupt file plus one sentence must not yield writes |
| `--read-only` | session-wide |

Anything not on that list is an `ask` an accepted attestation may answer.
One list, one opt-in, no philosophy.

### Attestation IS pre-committable. Say so.

Principle 2 requires an escape to be non-defaultable — "impossible to
pre-commit to a script or reduce to muscle memory." **An attestation
fails that test**, and pretending otherwise would be the dishonest part
of this design. A line in an agent's system prompt — *"when ebman asks,
attest that the operator authorised it"* — is a runbook, and for an
agent, prose is script. The incentive is one-way: attesting costs a
sentence, refusing costs the task.

So the free text is **not** the safeguard. It will converge on
boilerplate, and the fortieth identical sentence is not evidence. The
safeguards are the opt-in above and visibility below. Expect routine
attestation where it is enabled; design for it rather than treating it
as an anomaly.

### Attested implies durably audited — a hard invariant

The design's only honest promise is *it proceeds on the record*. Today
that promise is unbacked: `write_audit_line` returns early if the cache
directory cannot be created and discards the write error
(`let _ = f.write_all(...)`), and the log rotates at 1 MiB with a single
backup. Best-effort is defensible for telemetry. It is fatal for a
mechanism whose entire deliverable is the record.

**If the `stage=attested` line cannot be durably appended, the ask is
unanswered and degrades to deny.** Write-through, checked, with the
dispatch gated on it. This is the one place in ebman where an audit
failure must stop the action, and it needs a test that shows it failing.

Consequences to build in:
- `stage=attested` fires the notify webhook unconditionally where one is
  configured, rather than only appending.
- Demo mode writes no audit lines by design, so **attestation is refused
  under `--demo`** — no carve-out to reason about later.

### The ask, per transport

- **TUI** — a confirm modal. Exists.
- **MCP with elicitation** — the client can put a question to a human.
- **MCP without elicitation** — no human reachable; attestation applies
  if the operator enabled it, otherwise deny.
- **CLI****`--attest "<claim>"`, not `--yes`.** The first draft said
  "`--yes`, already the shape", which contradicts Principle 2 in the
  same document: a bare boolean flag is the defaultable escape that
  principle exists to prevent. If the CLI can answer an ask at all, it
  answers in the same auditable, non-boolean form as every other
  transport.

**A declared elicitation capability is a label, not a human.** The same
skepticism this document applies to `clientInfo.name` applies here, and
the first draft forgot it. A framework that declares `elicitation: {}`
and routes the question back to its own model gets its asks "satisfied"
with no attestation event and no free-text claim — **a cleaner audit
trail than the honest attester**, which is precisely the wrong incentive.
So: audit the ask as well (`stage=asked`, with the question and the
answer), and never describe elicitation as "a human confirmed".

### The honest limit

**A false attestation is not detectable at the time.** An agent that
says "the operator authorised this" and was not told so will proceed.
What the design buys, where an operator has enabled it, is that this
happens on a durable record, against an action already within the
principal's ceiling, in a form a human reviews afterwards. That is a
smaller claim than "prevents misuse" and it is the true one.

Where the operator has *not* enabled it, the answer is refusal, and for
a contained agent that refusal is real prevention rather than a detour.

### Structure, not prose: where the rule lives

A buildability review supplied a better mechanism than the list above,
and it is worth stating as the design rather than an implementation
note.

**Keep `decide` pure and keep the attestation out of `WriteContext`
entirely.** `decide` returns `Ask` for level-default cases and `Deny`
for pins, freezes and unreadable config; the *caller* satisfies an `Ask`
with an attestation. The transport layer then **physically cannot
convert a `Deny`** — there is no code path from an attestation to the
decision function.

That turns "an attestation may raise a default, never override a
decision" from a sentence someone must remember into a shape the type
system enforces. It also keeps the seam extractable for `pgman`, which
was the reason `WriteContext` holds only borrowed values.

### The gap that makes levels bigger than they look

`decide` **takes no action today.** It answers "may anything write to
this env", never "may *terminate* this env". A ladder that distinguishes
reversible writes from irreversible ones cannot be evaluated without
per-action attributes in the context, and the attributes that exist
(`ToolAttrs` in `cli/mcp/annotations.rs`) are `pub(super)` inside the
MCP module — unreachable from `write_gate` and the TUI.

Worse, there are **three action vocabularies with no mapping**: MCP tool
names (`dlq_purge`), the TUI's toast verbs via `deny_write_as`, and the
CLI's `action_label` strings. The backlog already carries "a neutral
action vocabulary, shared by refusals and dispatches" as a stage-5
prerequisite; this is the thing that makes it load-bearing rather than
tidy. Budget it as part of levels, not as a separate nicety.

### Elicitation is a frame-loop change, not a handler arm

The first draft treated "ask over MCP" as available once the capability
is detected. It is not. The server's loop treats every inbound frame as
a client request and matches on `method`; a client's **response** to a
server-initiated `elicitation/create` — a frame with an `id` and a
`result` and no `method` — has no route, and today would be answered
with an unknown-method error.

Implementing it needs a server-side request-id allocator disjoint from
client ids, a pending-elicitations map, and the frame loop learning to
distinguish responses from requests — a change to that loop's
fundamental contract.

And it collides with something the design never mentioned:
**`TOOL_TIMEOUT_SECS` is 30**, while a human answering a question
routinely takes longer. Either elicitation-bearing calls get a much
longer bound — a client-visible behaviour change — or the module's
documented "every call inside the 30s tool bound" contract is rewritten.
That is a maintainer decision, not an implementation detail.

### Config has two consequences the draft missed

- **The serializer deletes what it does not emit.** `:settings` rewrites
  `config.toml` from the parsed config, and passthrough only preserves
  lines the parser did *not* recognise. A `safety.level` the parser
  understands but the serializer never writes is **deleted on the next
  save**. Emission plus a round-trip test is mandatory before any config
  example is published.
- **Older binaries refuse everything.** A `safety.level` line hits the
  0.37 fail-closed catch-all in any earlier ebman, which means
  `SafetyConfigUnreadable`, which means every write refused including
  the TUI's. Correct by design, and worth one sentence in the docs so it
  is not discovered on a second machine.

**A contradiction to resolve before building:** an earlier section says
an unparseable *or absent* safety stanza resolves every principal to
`observe`. Applied literally, a fresh install's TUI operator is
read-only until they configure something — which breaks every existing
user and contradicts this document's own migration guarantee. The
buildable reading is that **absent** means per-transport defaults (the
TUI keeps today's behaviour; MCP is already gated by `--allow-writes`),
and only **unparseable** forces `observe`. The document must pick one;
as written it is not implementable without a regression.

### The smallest useful slice, and it is not levels

**Scope `--allow-writes` by verb**: `--allow-writes=dlq_delete,dlq_resend`.

It resolves the forcing incident directly — the client wanted to delete
one dead-lettered message and could only be given terminate-on-Prod. The
gating point already exists and is consulted both when advertising tools
and at dispatch, so unscoped verbs stay unadvertised, which satisfies
the "not even advertised" row of the table above.

It is pure ceiling, so Principle 5 holds trivially: it is a server flag.
It touches no config parser, no decision type, no audit stage, and
prejudges nothing — a level later compiles down to a verb set.

Roughly half a day against roughly two weeks for the full design. If
only one thing from this section is ever built, it should be this one.

### What has to be true before implementing

1. **Real elicitation data.** If most clients declare it, attestation is
   the exception rather than the norm and the docs should say so.
2. **Levels first, attestation second.** An attestation answers an
   `ask`; without levels there is nothing that says `ask`, and building
   it first produces a key looking for a lock.
3. **The config parser must fail closed** — done in 0.37.0.
4. **Do not wire `ask` to the existing `confirm_token` flow.** It is the
   path of least resistance and it is agent-confirms-itself with extra
   steps. This wants a pinned test, shown to fail, not a paragraph.
5. **The attestation is agent-authored free text** — the most
   attacker-shaped string the audit log will ever carry. It goes through
   `escape_value` like every other value and gets its own case in the
   existing forge guard, which exists because a crafted field could
   otherwise inject a second `stage=` token that consumers read instead
   of the real one.
6. **Scope is a description, not a limit.** An attestation covers one
   dispatch and is dropped when a plan is superseded — but nothing stops
   an agent re-planning and re-attesting with the same sentence. "This
   session" is also unenforceable across processes: two servers share
   only the freeze marker and the config. Say what it does, not what it
   sounds like.

## Adopt, don't invent

Most of this exists. Inventing it again costs interoperability.

| Borrow | From | For |
|---|---|---|
| `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | MCP tool annotations | Letting *any* MCP client render the right consent UI with no tool-specific knowledge |
| `principal / action / resource / context` | Cedar (AWS, open source) | The decision model's shape — familiar, and AWS-native suits the domain |
| `allow` / `ask` / `deny` | polkit, sudo, Claude Code | The tri-state from Principle 4 |
| `prevent_destroy` | Terraform lifecycle | The per-resource pin. `safety.envs.*.read_only` is already this |
| verbs over resources | Kubernetes RBAC | An action vocabulary, rather than a bespoke one |

**MCP annotations are the first thing to do.** `ebman` emits none today
(verified 2026-09-09) — an MCP client cannot currently distinguish
`list_environments` from a write tool except by reading prose. It is
contained, standards-compliant, and tells us how clients actually behave
before we commit to anything larger.

*(Check the field names against the current MCP spec revision before
implementing — the list above is from memory of the schema, not from
reading it today.)*

## What is genuinely ours

No standard covers these, so they are the shared invention — and the
part `pgman` reuses.

**Levels.** A short ordered set, sugar over rules. Working names:

| Level | Intent |
|---|---|
| `observe` | Reads only. The safe default for an unattended agent. |
| `guarded` | Reversible writes on non-production. Anything else asks. |
| `trusted` | Production writes allowed; irreversible ones still ask. |
| `unrestricted` | No level-based refusal. Confirms and audit remain. |

Per-principal, so an agent and a human can differ. Written as dotted
keys, because ebman's config parser is a line-based `key = value` reader
with no section headers — the first draft used TOML tables the parser
cannot read:

```toml
safety.level = "trusted"                       # a human at the TUI
safety.principals.mcp.level = "guarded"        # anything over MCP
safety.principals.mcp:claude-code.level = "observe"
```

**The effective level is the MINIMUM of every matching entry, never the
most specific one.** This is not a detail — most-specific-wins opens a
hole straight through Principle 5. The MCP principal is identified by
`clientInfo.name`, which is **self-reported by the caller** (ebman uses
it today only for audit tagging). Under most-specific-wins, a client
that simply renames itself stops matching the stricter entry and falls
back to the more permissive transport default: the caller would have
raised its own ceiling with a parameter.

Taking the minimum means a name-specific entry can only ever *tighten*
below the transport ceiling. And the docs must say plainly that
`clientInfo.name` is self-reported — it is a label for auditing and
convenience, not an authenticated identity. Principle 1 again.

**Fail closed.** `config::parse` today silently skips any line it cannot
read, so a typo'd `safety.envs.prod.read_only` vanishes and the
environment is writable. A levels stanza inheriting that posture would
mean `level = "observ"` silently granting the default. An unparseable or
absent safety stanza must resolve every principal to `observe`, and that
requires changing the parser's error handling — so it is work, not a
sentence.

**Precedence, and what it means for migration.** Pins and freezes are
level-independent and always win: `unrestricted` means "no *level-based*
refusal", not "no refusal". So an existing `safety.envs.*` config gets
strictly no weaker when levels arrive, which is the migration guarantee.

**The refusal document.**

```json
{ "decision": "deny",
  "rule": "safety.level",
  "principal": "mcp:claude-code",
  "action": "terminate",
  "resource": "api-prod",
  "retryable": false,
  "remedy": "human",
  "detail": "terminate requires level >= unrestricted; principal is at guarded" }
```

`remedy` says what would make this work, and is the field an agent
should branch on:

| `remedy` | Meaning | Agent should |
|---|---|---|
| `human` | Someone must raise the level, thaw a freeze, or act themselves | stop and report |
| `config` | A pin is in the way | stop and report which pin |
| `modify-request` | The action as *shaped* is refused; a narrower one may pass | rewrite and retry once |
| `wait` | A time-bounded window will expire | retry after `retry_after` |

Three corrections to the first draft:

- **`modify-request` was missing**, and it is pgman's most common
  refusal: a `DELETE` with no `WHERE` clause is blocked while the same
  delete with a predicate is allowed. An agent told `human` there
  escalates when it should simply narrow the statement.
- **A freeze is not `wait`.** ebman's freeze marker has no TTL — it ends
  when a human thaws it or the owning process dies. An agent given
  `wait` will poll *through an incident*, which is precisely when it
  should be quiet. Freeze maps to `human`. `wait` is only legitimate
  with a mandatory `retry_after`; without a horizon it is an invitation
  to spin.
- **`other-resource` is dropped.** "This action is fine elsewhere",
  without saying where, invites an agent to enumerate the fleet probing
  for something unpinned — the side-door behaviour Principle 3 exists to
  prevent. If the constraint that failed can be named, name it in
  `detail`; otherwise say nothing.

`retryable` is derivable from `remedy` and is kept only as a
convenience. Two fields that can disagree eventually will, so it is
defined as `remedy == "wait"` and nothing else may set it.

Every document carries a **correlation id** shared with its audit line,
which is what makes Principle 8's near-miss question answerable.

**Refusals are not audited today.** `audit.rs` records dispatched,
completed, skipped and undone — there is no refused. A TUI refusal is a
toast and an MCP refusal is a prose error, so the agent that tried
`terminate` on prod six times leaves no trace. Principle 8 should be
read as requiring a line for every decision including denials, not only
for allows.

## Three things the first draft got structurally wrong

### A decision is not one of three values

`allow / ask / deny` is not enough, and pgman already proves it. Its
shipped `Decision` (`pgman/src/safety.rs:231`) carries a `Guard`
alongside `wrap_in_tx`, `blocked_by_read_only` and `read_only_escape` —
because the useful answer is often *"allow, but wrapped in a
rollback-able transaction"* or *"allow, but with a 30-second statement
timeout"*. ebman has the same shape without naming it: the 5-second undo
window and the type-to-confirm purge are conditions attached to an
allow.

polkit and XACML call these **obligations**. The decision type needs an
opaque obligations channel from day one, or every tool bolts its
mitigations on beside the engine and the shared-seam story dies quietly.

`ask` is likewise not one thing. A keypress confirm and a
type-the-resource-name confirm are different strengths, and Principle 2
says the difference is the whole point — so ask carries a strength, or
the vocabulary supplies it.

### `ask` is a transport question before it is a policy question

The levels ladder is *defined in terms of* asking, so a ladder whose
middle rungs cannot be expressed over the primary agent transport is
sugar over nothing. A stdio MCP tool call cannot casually block on a
human. MCP added **elicitation** for this, but client support is uneven.

The trap: ebman already has a two-phase `confirm_token` flow on its MCP
write path, and an implementer will wire `ask` to it and believe the job
is done. Read that code's own comment — *"the agent that plans is the
agent that receives the token"*. That is **agent-confirms-itself with
human visibility**, not a human in the loop. It is a reasonable
mechanism; it is not `ask`.

So `ask` must be specified per surface, and the degradation named:

| Surface | `ask` becomes |
|---|---|
| TUI | the existing confirm modal, at the required strength |
| CLI | interactive prompt when a TTY is present; otherwise refuse with the document and exit 3 |
| MCP | elicitation where the client supports it; **otherwise `deny` with `remedy: human`** — never silently downgraded to allow |

**Decided 2026-09-09, and the "where the client supports it" is now
measurable rather than hopeful.** Elicitation is a *client* capability
declared at `initialize`, so the server can know per connection whether
`ask` is expressible on that transport. ebman was discarding that field;
it now captures and logs it (`client_supports_elicitation`), and nothing
branches on it yet.

That ordering is deliberate. The open question was whether elicitation
is usable in the clients that matter, and the design note could not
answer it. Guessing would have meant either building a middle rung that
silently degrades to deny for everyone, or assuming support that is not
there. Logging what real clients declare answers it with data before
stage 5 depends on it — and if the answer turns out to be "almost
nobody", that is the stop condition firing with evidence behind it.

Two properties the detector must have, both pinned by tests: a client
declaring elicitation is recognised (or `ask` degrades to a refusal for
everyone, and the ladder's middle collapses), and a client declaring
nothing is *not* treated as able to answer (or a level that should have
asked will silently proceed).

Note also what `ask` may **not** be wired to. ebman's two-phase
`confirm_token` flow looks like the obvious mechanism and is not one:
its own comment records that the agent which plans is the agent which
receives the token. That is agent-confirms-itself with human
*visibility* — a reasonable safeguard, and not a human in the loop.

### Decisions are not always one-shot

The model assumes a single pre-flight "may I?". ebman's own multi-region
rollout already disproves that: it re-reads the freeze *between*
regions, halts the un-dispatched ones, cannot recall those already sent,
and reports `skipped (rollout halted)`. So the engine needs
re-evaluation points and partial-completion semantics, and the refusal
document needs somewhere to say "stages 1–3 already executed".

This is worse for a tool whose action *is* a pipeline: an `ask`
arriving at stage 3 of 7 must pause something already running, which is
an async approval rather than a modal.

## Splitting it for reuse

**Engine — genuinely neutral, and smaller than it first looked.** The
decision type (with its obligations channel), the refusal-document
schema and serialiser, preset expansion *given tool-supplied rung
definitions*, the `safety explain` renderer, the audit record shape.

**Vocabulary — per tool, and it owns most of the hard work:**

- **Action derivation**, not merely action names. ebman's actions are an
  enum. pgman's come from `classify(sql)` — hundreds of lines of
  heuristics with CTE unwrapping, `WHERE`-refinement and escape
  detection. The engine consumes a classified action; producing one is
  the domain problem.
- **Attribute computation as functions, not tables.** "Reversible" is
  not a property of a verb. In pgman it is *manufactured* by wrapping in
  a transaction; on a Kubernetes tool, deleting a pod with an owner
  reference self-heals while deleting a PVC is data loss — same verb,
  and the answer depends on live state. So context gathering can involve
  I/O before a decision is even possible.
- **Resource resolution and hierarchy.** ebman's pins are a two-level
  lookup (env, then account). A namespaced tool needs ancestry — a pin
  on a namespace must cover a pod inside it. Cedar's answer is entity
  ancestry, which this note borrowed the four-tuple from and then
  dropped; either the engine grows ancestry matching or the vocabulary
  supplies `applicable_pins(resource)`, and the latter moves real policy
  logic back out of the engine.
- **Obligations**, per the section above.

### pgman is a migration, not a greenfield consumer

An earlier draft invented a pgman vocabulary — "databases, roles; query
/ vacuum / drop / kill-session". That was written from imagination, and
it is wrong. **pgman already ships a 2,375-line safety engine**
(`pgman/src/safety.rs`) with its own config at
`~/.config/pgman/safety.toml`. It has no role resources and no
kill-session action; `VACUUM` is folded into an `AlterDdl` category; and
its real action space is heuristic classification of free-text SQL.

Two consequences, and the first is the encouraging one:

**pgman independently arrived at `Guard { Allow, Confirm, Block }`** —
the tri-state of Principle 4, reached without coordination. That is the
strongest available evidence that the principle is real rather than
invented.

**But its ladder is a vector, not a scalar.** pgman configures a
per-database profile holding a tri-state *per statement category*
(insert / update / update-without-where / delete / delete-without-where
/ truncate / drop / ddl), plus orthogonal dials like
`statement_timeout_ms` and `auto_tx`. Its danger axis is *blast radius
of the statement* — a `WHERE`-less DELETE outranks a keyed DELETE on the
same table — where ebman's is *resource criticality × reversibility*.
The endpoints (`observe`, `unrestricted`) map cleanly; the middle rungs
do not decompose the same way.

So: rung **names** may be shared, rung **definitions** are per tool. And
that carries a cost worth stating — an operator or agent moving between
two tools will assume `guarded` means the same thing in both. If the
ladder is not genuinely comparable, do not reuse the names.

Note also that pgman's only principal today is a human at the TUI; it
has no MCP server and no headless write path. Its live override axis is
the **resource** (which database), not the principal. A design that
leads with `[safety.principals.*]` points its implementer at the wrong
axis first.

### On the tb-tui-common precedent

That crate shares theme, overlay, splash, font probing and text input —
presentational code with no domain meaning. It settles the *packaging*
question (crates.io publication, path override for co-development). It
does not settle whether policy **semantics** split cleanly, and the
first draft's "settled by precedent" over-claimed.

## What this actually costs in ebman

An earlier draft of this note claimed the write path funnels through one
pure gate, so widening it would be cheap. **That is wrong**, and the
correction changes the plan rather than a sentence.

There are **two** gates, and the divergence is deliberate:

- `cli::write_refusal` — CLI, MCP, and `lint --fix`.
- `App::read_only_reason` / `is_read_only_for` (src/app/safety.rs) — the
  ~25 TUI dispatch sites.

`src/config.rs` says so outright: the TUI paths *"deliberately do NOT
route through this — they compose additional session gates (global
`--read-only`, `:freeze-deploys`, demo mode) with their own precedence
and toast wording; only the config-pin layer is shared semantics."*

Nor does a guard pin "every dispatch site". The one that exists scans
`src/cli` for direct `pin_reason` calls — it catches the
half-composition that produced 0.14.1, not a path that calls neither
gate. TUI coverage is a different mechanism: behavioural sweeps over the
hand-maintained `WRITE_COMMANDS` list. And `write_refusal` is not pure —
it reads `AWS_PROFILE` from the environment as a fallback.

So the milestone order is:

1. **Converge the two gates** onto one decision function taking a
   fully-materialised context (no ambient env reads, no clock). This is
   the hard part and the note's original framing hid it. It is also what
   makes the engine extractable at all.
2. **Emit MCP annotations** — independently useful, and see below: it is
   also how the action vocabulary gets built.
3. **Levels** on top.

Shipping levels before step 1 means the shared crate is consumed by
ebman's CLI while ebman's own TUI stays on a fork — the worst possible
advertisement for it.

The residual risk is the inverted one: it is easy to make a gate richer
and hard to keep call sites honest about the new distinction. This repo
has been bitten exactly there — a widened `Option` flattened back by
`unwrap_or_default()` at every caller. After widening, grep for what
destroys the distinction and pin it with a guard.

## Deliberately not proposed

- **A policy language.** Levels plus per-resource pins cover the cases
  we have. Rego or Cedar-as-text is a large surface for a need nobody
  has demonstrated; revisit only when a real rule cannot be expressed.
- **Time-based rules** (no Friday deploys) in v1. Principle 7 wants
  them; they need a clock in the decision context, which is a testability
  question worth settling separately.
- **Anything that implies a security guarantee.** See Principle 1.
- **Hierarchical resources** in v1. Cedar's entity ancestry is the known
  answer and this note borrowed the four-tuple without it; ebman's pins
  are a flat two-level lookup and that is enough for environments. A
  namespaced tool needs it before it can adopt this.
- **Sharing rung names across tools whose ladders are not comparable.**
  See the pgman section. Shared names with different meanings are worse
  than different names.