satz mcp

The estate, over the Model Context Protocol. An MCP client — Claude Code, Cursor, any other — drives satz. satz calls no model, needs no API key and is deterministic; only the tools marked openWorldHint below reach the network.

satz mcp                          # read-only, the default
satz mcp --allow read,write       # …and may write files in the estate
satz mcp --allow read,write --self-gated

It speaks JSON-RPC on stdio and is started by the client, not by you.

Started by hand, it exits immediately, because the client speaks first:

satz mcp: stdin closed before the client said hello — nothing to serve, exiting.

To exercise it by hand, pipe a handshake in:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | satz mcp --root /path/to/estates

What an agent may do

Three capability groups, granted independently. Each covers a different kind of consequence, not a severity level:

group what it covers
read compile and report. Nothing is written, nothing external runs
write writes files inside the estate — hcl/, adopted ids, the preset library
exec runs an external tool, or changes a live organisation

--allow sets a ceiling the client cannot raise. With --self-gated the client may lower its own level at runtime through the satz_restrict tool and can never raise it again — so an agent can prove it stayed read-only for a phase of its own work.

The ceiling bounds this server and nothing else. An agent that also has a shell of its own — Claude Code's Bash tool, say — can run any satz command there, apply included, with the rights of the user who started it; what the ceiling is for is an agent whose only way to satz is this server.

At every level self-update is not exposed (it replaces the binary), and every path argument is confined to the directory the server was started in: use "…" resolves through include_dirs, so an estate can pull in other files.

Resources — what an agent reads before it writes

A client that only speaks MCP has no repository, and the tool schemas do not describe the language. The server serves the documentation:

resource what it is
satz://guide satz llms — the working subset, the three grant forms, and the order to call the tools in. Read this before writing a .satz file.
satz://reference the complete language reference
satz://presets the preset library and its provenance rules

They are compiled into the binary, so they match the server's version and need no path. The instructions returned at initialize name the guide. A unit test parses every satz block in the guide.

Tools

tool group what it answers
satz_estates read which estates this server can open: every config.toml under its root, with the estate files beside it — each with its deployment_mode as the compile reads it, or, for one satz_open refuses, refused with the reason and no mode
satz_open read open one for the session — its config.toml and its main .satz. Answers with what it resolved, including the identity that estate's live tools will run as — runs_as, null when they run as the credentials themselves (local mode, or satz --no-impersonate mcp)
satz_require read which controls of a catalog the declared estate satisfies, from its packs' claims, and per control its measures: every pack that claims it, included or not, with the gcloud commands that meet and check it without satz and the risk its pack states. Offline
satz_questions read every question the estate's packs declare with its state — answered when the estate's own params bind it, else unanswered with the default the pack offers or blocking when none is possible, and each param question's declared shape (string | number | bool | list | map) — and summary.complete, the gate satz bootstrap and satz transpile --apply refuse on
satz_interview read / write the interview: the open questions (or all, with filter: all), each with its pack's description and its offer. With write: create writes the estate first, answers writes what the human decided (for a choice, the option's param name, or none where the choice is not required), accept_defaults writes every offer — and the report comes back as it now stands. An answer whose shape contradicts the question's shape is refused and nothing is written. create writes the day-0 estate: eighteen questions, and every pack's use line commented out under the phase it can be adopted in, from the pack-graph.json in presets_dir — with none there, the estate is written without pack lines. Answering a pack's question true switches its line on as satz_add_pack does — uncommented, or written where the pack graph places it — so an answer and what the estate uses cannot disagree; like it, the answer is refused while a pack it needs is off, and an answer that would leave an estate satz refuses writes nothing. notices carries what those answers opened: a pack that names a command to run once it is on (§6.15) — its text, the run, and the param to answer true through answers once the command has run. contributes carries what the packs those answers switched on add to other packs' list params (language: contributes_<param>) — each with the pack, the param, its values, and applied, false when nothing the estate uses declares that param. A call that answers nothing returns neither. core says what the call did about a commented-out use "presets/estate-core.satz" line — the one satz init writes without --interview: a session with write switches it on; otherwise why it was left. satz interview
satz_packs read every pack the pack graph offers, as the estate has it — the rows satz-studio's Packs view shows: the gate with the estate's answer, the library's default and the value they give; the line (active, ungated, commented, absent, forked, misplaced) and its number; whether it deploys; what it requires (each with met), what it is required_by and what it excludes; the notices it carries, each with its severity and acknowledged; what it contributes to other packs' list params while it deploys, each param with its values; and the compile's findings about it. A use the graph does not know is unmanaged; with no pack-graph.json in presets_dir, note says so. The same value satz packs --format json prints
satz_check_consumer read a project's HCL beside the estate, held to the estate's interface as satz check-consumer holds it: dir names the project's directory of .tf files under the server's root. findings lists, at the project's file and line, an attachment resource onto the estate's object that no attach point (export … attach [ … ]) allows, an authoritative grant (*_iam_policy, *_iam_binding) or an organisation policy on a node the estate manages, and a resource the estate declares too, by natural key; an empty list is a pass. resources counts the project's resource blocks read. Offline; writes nothing
satz_add_pack write switches a pack on by its gate or path, as satz add-pack does: the gate bound true, the line made active where the graph places it, the packs that follow its gate with it. Refused, naming them, while a pack it needs is off (with_requirements switches those on where the graph names one) or one it excludes is on. The edited estate is compiled and restored when it does not compile. Returns what was bound, which lines moved, the questions that opened, in notices the commands the packs it switched on ask to be run — each acknowledged by answering its param true through satz_interview once it has run — and in contributes what those packs add to other packs' list params: billing_export adds Google's export account to the CIS baseline's allowed_policy_member_subjects, for one. satz add-pack prints the same lines
satz_remove_pack write switches a pack off, as satz remove-pack does: the gate bound false, the line left. Refused, naming them, while a pack that needs it is on (cascade switches those off too) or while its line is not gated on its gate. The edited estate is compiled and restored when it does not compile
satz_triage read a Prowler export's FAILs sorted into buckets A–E against what the estate claims
satz_prowler read the Prowler invocation this estate needs — the frameworks it is HELD TO and those its packs CLAIM, together; the projects; and an output path named for the UTC minute, so no two scans share a file; unresolved_projects names each project left out because its id is built from a reference to another resource. It prints the command; nothing runs the scanner
satz_transpile_check read compiles in memory and reports what it would emit — writes nothing; findings carries what the compile said, each at its file and line, by kind and subject — the pack, the notice's param, the action's name, the hcl block's file:line — with message, the sentence, and fix, the command that answers it, where one command does. A finding a tier silenced is in the list too, carrying silenced with that tier and its reason, so an agent sees what a human's output leaves out. A refusal is an error result that carries the same list — the front end's too, as one front-end finding — so an error is shown at its line rather than parsed out of the sentence; its text block is the errors as the CLI lays them out. satz transpile <estate> --check --format json prints this object
satz_check_presets read which packs are clean, behind upstream, locally edited, or changed only in the questions they ask
satz_fmt read Satz text in, the canonical layout out — two-space indent, = aligned over a run, one list item per line. No path and no write: the client holds the file. satz_review_pack refuses a pack that is not formatted, and this is what formats it
satz_review_pack read one pack against the library's bar — it parses, is formatted, its header says what it is, its id is its file name, its version has a changelog row, it carries no value shaped like private data (kind private-shape, one finding per token), it declares no membership, it runs no legacy constraint beside its managed replacement, every type it emits has a prerequisite row, it compiles, and every string param it leaves "" has a question. Folded into a synthesised estate unless against names one. Findings anchored to file and line
satz_report_compliance read the goal view joined with live verification through Cloud Asset Inventory, attestations and optional Prowler corroboration, each control with the same measures as satz_require, read as the estate's IaC service account (cloud mode) or as the credentials themselves (local mode). Checkov's column is not served here — satz_scan_checkov runs Checkov — and neither is --fail-on: the report carries every status, and an agent judges them. framework is optional: named, the answer is that catalog's report; omitted, it is {frameworks, reports} — one report per framework the estate is HELD TO (compliance_frameworks)
satz_whoami read both halves of the identity — the ADC account, and what the open estate's calls run as: its mode, the service account it declares, and whether that account is impersonated — with the live checks that decide whether the next call works: may this credential become that account, is the quota project reachable, does it hold the permissions the estate's resource types need (permissions, each missing one named with its role). The first thing to check when a live call is refused
satz_transpile write compiles the estate and writes its OpenTofu HCL into hcl_dir, as satz transpile does; written lists the files; inputs (name → value as text) binds a template's inputs for the run, as --input <name>=<value> does
satz_adopt read / write; out also write every declared resource resolved against the live organisation, as the estate's service account: per row whether it would be imported, moved in the state, is already managed, is created by the next apply (an IAM grant the live policy of its parent does not hold, or whose parent does not exist yet), or cannot be resolved, what it matched on, and the Satz line that declared it. rows carries the rows that ask for something — a decision, a state move, an import, in that order — and at most fifty of them; rows_total, rows_omitted and note say what is not there and where the rest is, and out writes the whole report as JSON to that path under the root. With execute (write) the verified ids are written into the estate as "import-id", refused while any row is unanswered or a live object is declared twice; written and hints are a line per resource and are capped the same way, with written_total and hints_total beside them. tofu import, a state move and activating a managed constraint stay on the command line
satz_update_prerequisites read / write what the estate's resource types oblige it to declare and it does not: the roles its IaC service account is missing (with the fewest roles that close the gap) and the APIs not enabled on the project their resources' calls are billed to (with the project and the types that need each), plus the emitted types the table has no row for. Offline. It WRITES both into the estate file and re-checks — a gap that survives the write restores the file — so the default needs write; report_only lists the gap and needs only read
satz_merge_presets write the reconciling update: what was installed, taken as doc/format only, forked to X.local.satz with the estate repointed, adopted in place, deferred or refused — as events in the walk's own order, with the counts and attention. adopt takes upstream in place for the packs named (all for every pack merely behind) and the answer carries the emission delta; report_only writes nothing. It writes the commented use line for a pack the library has that the estate has none for, so a new pack's question never binds an answer that nothing emits, and last writes the roles and APIs the estate's packs need as satz_update_prerequisites does — a prerequisites event, at validation level error too
satz_get_presets write the upstream library into the open estate's presets_dir: missing files installed, identical ones left, changed ones the estate does not use refreshed; a pack the estate uses that upstream changed is refused unless force. pristine_dir copies from a library under the root instead of downloading. presets_dir must be inside the root
satz_remediation_items read the remediation dossier's items for an estate and a Prowler export — triaged, deduplicated, joined per (control, resource) — with the dossier_sha256 authored values must name. The worklist for the [Authored] columns. checkov names a Checkov JSON report under the root — the one satz_scan_checkov writes to its out — and its findings join the dossier's; the tool reads it and runs nothing
satz_remediation_annotate write writes authored values (what_why, recommended_fix, owner, effort, phase, quick_win, risk_acceptance, and the mandatory authored_by and authored_at) per item id into <out>/authored.json, merged with what is on file, and renders the run there with the [Authored] columns filled. It takes the prowler export and the checkov report the items were built from. Refused when the hash is not the current dossier's, an id is unknown, or an entry names no author
satz_scan_checkov exec; out also write Checkov over the HCL in hcl_dir — the counts, and every failed check with the Satz file and line that declared the resource. Scans what is written: transpile first. Runs checkov, else uvx checkov, with its output captured. With out it writes Checkov's JSON report to that path under the root, the file satz_remediation_items and satz_remediation_annotate read as checkov
satz_restrict — lowers this session's level; only with --self-gated

Each returns the same value the corresponding --format json command prints, with one exception: satz_adopt, whose table has a row per declared resource, returns the rows that ask for something and says how many it left out — a terminal has no output limit and a client has (result size).

Which commands an agent can run is a decision per command. MCP_PARITY (src/mcp.rs) names every CLI command with the tool that serves it or the reason none does, and a command in neither column fails cargo test — the same way every command must declare an identity. What is not served, by class: the commands that hand stdio to tofu (plan, apply, hcl-init), the day-0 ones that run as the human (init, bootstrap), the one-off that moves an estate between deployment modes as the operator's own credentials (migrate), the ones that write to an organisation (run-actions, adopt-org-policies), the live sweep that creates an estate from what exists before there is one to open (import), the specialist org-policy tools the compliance plane answers for (export-, diff- and report-organizational-policies), the maintainer refreshes of shipped data (map-types, update-schema, doc-packs, pack-graph), the tofu-workflow plumbing (scan-plan, generate-migration), what decides a human's own output (silence — an agent is handed every finding, the silenced ones included and marked), the client configuration that sets this server's own ceiling (mcp-config), the interface plane's commands, which have no tools (interfaces is the report satz-studio reads through the CLI, check-request runs in a project's pipeline on the pull request that vendors the file), and the terminal affordances (completion, open-readme, self-update, help). Every one of them except the terminal affordances, mcp itself and lsp is named with its reason in the instructions the server returns at initialize, so an agent asks a human for it. The table in src/mcp.rs is the full list, with a reason per command. The tools the table has no command for — satz_open, satz_estates, satz_restrict — are the session and capability plumbing a terminal does not need.

A tool the level does not permit returns a tool result with isError, not a protocol error, so the agent can continue. A missing argument, or one of the wrong type, is refused the same way: an isError result that says what did not deserialize (missing field `framework` ), not a JSON-RPC invalid_params error.

satz_report_compliance reads live and writes nothing. The CLI command appends every run to the append-only evidence history, the audit trail of reports someone ran; the tool does not append.

The rows are data, not a rendered table. Each witness is an object — address, live state, the id it matched, and declared_at, the file:line of the Satz that declares it. Each control carries a responsibility (inherited · customer · shared · satz-managed · unassigned) and the report carries the estate's commit and whether the tree was dirty. An agent renders the audit list, the spreadsheet or the remediation commands from these fields. declared_at points each witness at the Satz line that declares it, at the report's commit.

A report that verified nothing says so. live is whether the inventory was actually READ, not whether it was asked for; live_status says which of the five outcomes it was — verified, skipped (--no-live), no-organization-id, no-witnesses, unavailable — and warnings carries the reasons, in the words the command prints to its terminal. The report degrades to unverifiable witnesses rather than failing the run; live and live_status tell a caller that cannot read stderr — an agent, a pipeline reading --format json — whether the witnesses were verified.

Undeclared exemptions have their own field. For an estate that declares the exemption key, exemption_bindings lists the live bindings of that key the estate does not declare, and each row's undeclared_exemptions the ones that let its witness policies out. Its status says whether the bindings were read; undeclared is null unless they were (language §8).

Structured output

Every data tool returns its report as structuredContent and publishes the report's outputSchema in tools/list. A client gets a typed value it can index, and knows the shape before it calls. The text block of the same result carries the same JSON, for a client that reads only text.

Each report is a JSON object with an object schema, as MCP 2025-06-18 requires, and is the value the command's --format json prints: satz_triage returns {"rows": […]}, as satz triage --format json does.

Where a report is too long for a client to read, what it leaves out is a field of the report rather than a difference between its two renderings: satz_adopt carries rows_total, rows_omitted and a note naming the file its out wrote, and the text block and structuredContent carry the same trimmed object.

Annotations

--allow sets what the server permits. Tool annotations tell the client what it may run without asking:

annotation on
readOnlyHint: true satz_open, satz_estates, satz_require, satz_questions, satz_packs, satz_check_consumer, satz_triage, satz_prowler, satz_transpile_check, satz_check_presets, satz_review_pack, satz_fmt, satz_report_compliance, satz_whoami, satz_remediation_items
readOnlyHint: false, destructiveHint: false, idempotentHint: true satz_update_prerequisites — report_only is free, the default writes the estate and is refused below write; satz_transpile — it writes, but re-running it converges; satz_remediation_annotate — the same values written twice leave the same run; satz_adopt — reading is free, execute writes the estate and out writes the table, and both are refused below write; satz_interview — reading is free, create/answers/accept_defaults write the estate and are refused below write; satz_add_pack and satz_remove_pack — they write the estate, and the same call twice leaves it as the first did; satz_scan_checkov — it runs an external program, and uvx downloads it first, so a client asks before running it, and out writes its report; satz_merge_presets — its report is free, and the write converges; satz_restrict — it lowers this session's level and nothing else
destructiveHint: true satz_get_presets — with force it overwrites packs the estate uses
openWorldHint: true (also) satz_merge_presets — without pristine_dir it fetches the upstream library
openWorldHint: true satz_check_presets, satz_report_compliance, satz_whoami, satz_scan_checkov, satz_adopt, satz_get_presets, satz_merge_presets — the ones that can reach the network (uvx checkov fetches Checkov)

Without annotations a client either prompts on every read or runs a write without asking. The ceiling decides what is possible; the annotations decide what runs without a prompt.

Claude Code

claude mcp add registers the server. satz takes flags of its own, so they go after --, which is where Claude Code's arguments end and the server's begin:

claude mcp add satz -- satz mcp --root /path/to/estates --allow read

Where it is written

--scope (-s) decides the file, and the file decides who sees the server:

scope the server is available written to
local (the default) to you, in the project you added it from your own file, under that project's path
user to you, in every project your own file, at the top level
project to everyone who clones the repository .mcp.json at the repository root, committed with it

Your own file is .claude.json in your home directory:

platform path
macOS ~/.claude.json
Linux ~/.claude.json
Windows %USERPROFILE%\.claude.json

CLAUDE_CONFIG_DIR moves it: set it, and the file is read from inside that directory. A project server is the same JSON in .mcp.json at the repository root on all three platforms, which is the scope that puts an estate's server in the estate's own repository.

Written by satz

satz mcp-config prints the block for one estate, and --write puts it in .mcp.json in the directory holding the estate's config.toml — the project scope above, which is where an estate's server belongs:

satz mcp-config C0example.satz                              # print it
satz mcp-config C0example.satz --write                      # write .mcp.json beside config.toml
satz mcp-config C0example.satz --allow read,write --write

satz fills in the three values a hand-written block gets wrong. The binary is the satz that prints the block, by absolute path, so the client needs no PATH of its own. The root is the directory holding the estate's config.toml, canonicalised. The ceiling is written out even when it is the default, because a block that omits --allow grants read without saying that anyone chose it.

--write merges satz's own key and leaves every other key in the file as it is, in the order the file has them; satz's key keeps its place when it is replaced and goes last when it is new. A satz key already there with other arguments is printed and refused — --force replaces it — and a file that is not JSON is refused and left alone, since satz cannot merge into what it cannot read. A second run writes nothing.

Written by hand

{
  "mcpServers": {
    "satz": {
      "type": "stdio",
      "command": "satz",
      "args": ["mcp", "--root", "/path/to/estates", "--allow", "read"]
    }
  }
}

command is looked up on PATH, so the installer's ~/.local/bin/satz resolves by name; an absolute path resolves anywhere. On Windows the PowerShell installer puts satz.exe in %USERPROFILE%\.local\bin and on the user PATH. A Windows path is JSON, so its separators are escaped — "C:\\Users\\you\\estates" — or written as /.

Give --root an absolute path. The client starts the server, so the working directory is the client's rather than the one you typed in, and a relative root resolves against it. The root is one directory holding many estates: one server serves a fleet, and the client opens an estate per call.

Check it

claude mcp list health-checks every server and prints the command behind each; /mcp in a session shows one server's tools and reconnects it. A server from .mcp.json is listed as pending until it is approved, so cloning a repository does not start a process without consent.

Result size

Claude Code cuts a tool result longer than MAX_MCP_OUTPUT_TOKENS (25,000 tokens unless the variable is set in its environment), and what it cuts is no longer JSON. MCP asks the text block to repeat structuredContent, so every row of a result costs twice its own JSON.

A result that carries a row per declared resource therefore carries what the caller has to act on rather than the whole table: satz_adopt returns the rows that ask for something — a decision, a state move, an import — most urgent first and at most fifty of them, with rows_total, rows_omitted and a note that states both numbers and names the two ways to the rest: out, which writes the whole report as JSON to a path under the root, and satz adopt <estate>, which prints every row in a terminal, where there is no limit. The trimming is in the report, not in the rendering: the text block still equals structuredContent, and both say what they leave out.

satz_report_compliance returns the largest results — around 19,000 tokens of verdicts and witnesses for one organisation against CIS GCP 5.0, plus the measures of every control, the library's gcloud routes and risks, about 57 KB more — and they grow with the estate and the catalog. It is the whole report or nothing: an evidence report that omits evidence is a different document, and it is the value satz report-compliance --format json writes. A call with no framework returns one report per framework the estate is held to, so its result is that size multiplied by however many it names. Where an estate outgrows the limit, name one framework per call, raise MAX_MCP_OUTPUT_TOKENS in the client's environment, or run the command and read the file it writes.

scripts/mcp-probe.py prints each tool's largest result in bytes and in estimated tokens, and fails the run on one over --limit-tokens (housekeeping).

Run the same command in a terminal to see what the client cannot show you: it holds the line waiting for a client, and says so when stdin closes.

Claude Desktop

Claude Desktop reads one file, which holds every server a person has:

platform path
macOS ~/Library/Application Support/Claude/claude_desktop_config.json
Windows %APPDATA%\Claude\claude_desktop_config.json
Linux ~/.config/Claude/claude_desktop_config.json

An entry in it is a command and its arguments:

{
  "mcpServers": {
    "satz-C0example": {
      "command": "/Users/you/.local/bin/satz",
      "args": ["mcp", "--root", "/Users/you/estates/acme", "--allow", "read"]
    }
  }
}

satz mcp-config C0example.satz --client claude-desktop prints that block, and --write merges it into the file at the path above — --file names another. The key carries the estate's name because one file holds every server: a second estate is a second key beside the first, and --name <KEY> writes a key of your choosing, which is what two estates with the same file name under two different roots need.

satz owns that one key. Every other server in the file, and every setting beside mcpServers, is read, kept and written back as it was, in the file's own key order; the refusals are the same as for .mcp.json. Claude Desktop reads the file when it starts, so restart it after a write.

The MCP SDK

satz serves MCP through rmcp. The smoke matrix drives the server as a client at protocol revision 2025-06-18, the revision every step of it speaks. The server answers a client's revision from 2024-11-05 to 2025-11-25 with the same revision, and any other with 2025-11-25.

scripts/mcp-probe.py drives every tool the way a client does, reading the server's stdout itself rather than through an MCP SDK, because an SDK drops a line that is not JSON and a test built on one cannot see a leak. It runs by hand, not in CI (housekeeping).

stdout is the protocol

Everything satz says to a human — the version banner, emitter warnings, the credentials: line — goes to stderr. Under MCP a stray line on stdout corrupts the stream, and the client reports nothing useful. The smoke matrix asserts that every line the server emits parses as JSON-RPC, and scripts/mcp-probe.py charges any line that does not to the call in flight. The background update check does not run under mcp or lsp at all, and the Update available notice every other command may print is on stderr, so --format json output starts with the JSON.

The matrix runs without credentials, so it never reaches a live tool call. Two unit tests cover that path: one scans src/mcp.rs, the other scans the code a tool reaches transitively — the token chokepoint gcp::access_token() and the announce path. A new live code path on a tool's route belongs in the second one.

One server, a fleet, one identity per call

The server holds no estate until a client opens one. satz_open names a config.toml and a main .satz; everything after works on that estate, under that config — its presets, its schemas, its provider version. Call it again for the next estate. satz_estates lists what is available under the root, so the first call is not a guess at a path.

The root, given as satz mcp --root <dir>, is a boundary, not a configuration: every config and estate a tool resolves must live inside it, and anything outside is refused by name. It is the only thing the server is started with: estates do not share a config.toml, so each estate's own config comes with satz_open, and satz mcp takes no --config.

A path is judged against the root before anything else is said about it. A path outside the root gets the same refusal whether or not something exists there, and the refusal names the path as it was asked, not where a symlink leads; only a path inside the root is told that it is missing. A path that does not exist yet — an output directory, an estate satz_interview creates — is judged where creating it leads, with .. applied the way a directory walk applies it. A relative estate name is read from the server's working directory when a file of that name is there and inside the root; otherwise it names a file in the config's yaml_dir. That is every estate argument — estate, and satz_review_pack's against.

An estate argument also has to belong to the open config.toml, because the call runs under that config's presets, include dirs and schemas: the nearest config.toml at or above the estate's directory is the open one, or, with none above it, the estate lies in the open config's yaml_dir. An estate of another config under the same root is refused, naming both configs; satz_open with its own config is how to work on it.

Which identity, and who decides

A live tool runs as the estate it is working on — the one its estate argument names, else the open one: for deployment_mode = "cloud", that estate's IaC service account, exactly as the same command does from the shell. Nothing is configured and no tool sets it. The ADC authenticates, and satz's first act is to exchange it for the account the estate itself names — svc_iac_account + infra_project_name, the same derivation the emitted provider block uses — read from the estate as it stands when the call is made. satz_open reports the result as runs_as so it is stated rather than assumed, and null there means the calls are the ADC identity itself — the estate impersonates nothing, or the server was started as satz --no-impersonate mcp; its deployment_mode is the mode the compile reads, local when the estate declares none.

An estate whose params do not parse, whose deployment_mode is neither local nor cloud, or whose deployment_mode = "cloud" has no value for svc_iac_account or infra_project_name, names no identity. satz_estates lists it with that reason as refused, satz_open refuses to open it, and satz_whoami, satz_adopt and satz_report_compliance refuse a call that names it, each with the estate and the reason; none runs as the ADC identity in its place.

The identity is scoped to the call, not bound to the process, so one server works through a fleet. It is a scope rather than a mutable global because the server dispatches requests concurrently: a global changed underneath a call in flight would run one estate's tools with another estate's credentials. Work started under one estate finishes under it.

satz_whoami answers for the open estate, inside the same scope the other tools use, or for the estate its estate argument names. With nothing open it answers for the ambient credentials, as satz whoami does with no estate; an estate named with nothing open is refused, because only an open estate's config resolves the name. Its estate object carries what the terminal's runs as: line says: deployment_mode, the service_account the estate declares (in local mode too), and impersonated — true when the calls run as that account, false when they run as the ADC account itself (local mode, or --no-impersonate). --no-impersonate outranks the scope: every tool then runs as the plain ADC.

Not available