satz interview

The interview asks the questions an estate's packs declare, one at a time, writes each answer into the estate as it is given, and keeps the estate off the organisation until no question is open.

There are three ways to start an estate, and they end at the same file:

who answers how
satz init --customer-id … --customer-shortname … --billing-account-infra … whoever runs it, all at once every answer is a flag; the estate binds every param and is complete on arrival
satz interview <estate> [--create] a person at a terminal one question at a time, the default in brackets, Enter accepts it
satz_interview over MCP a customer, through an agent the agent asks in its own words, the human decides, the agent writes the param or passes it as an answer

init takes ten of the day-0 values as flags and writes the rest from its defaults. The other two ask for every day-0 value, and also ask every question a pack adds, which init has no flag for.

What a question is, and what an answer is

A pack that needs a param decided declares a question beside it — what to ask, why, and what changing the answer later costs (the language reference has the syntax). The estate uses the pack; the questions come with it.

An answer is a param the estate's own params {} binds. To the pack's default or to anything else. Accepting a default is an answer, and it is recorded by writing the default into the estate — so a reader of the file can tell "decided" from "never looked", which the fold alone cannot, because a pack default and an estate's decision resolve to the same value there.

A param is bound once. Every command that writes one — init on a re-run, the interview and satz_interview, add-pack and remove-pack, the notice adopt --execute --import acknowledges, migrate's deployment mode, bootstrap's greenfield write-back — replaces the binding the estate already has, where it stands: the line keeps its indentation, its = column and its trailing comment. A param nothing binds yet is appended to the block, and the block is re-aligned. A param bound twice in one estate is refused by name, with both lines.

The states satz questions reports:

state meaning
answered the estate binds the param (for a oneof: binds one of its options)
unanswered it does not. The row carries the default the pack offers, if one is usable
not-applicable the question's ask_when param is false; not asked, not counted

and one flag: blocking — unanswered and no usable default. A directory customer id, a billing account, a domain: the pack can name the question, it cannot guess the answer. These need a typed value.

Each param question also carries its shape — string, number, bool, list or map — as the pack declares it (access_approval_notification_emails = [] is a list), so an answer to a question with nothing to offer still has a shape. The interview reads a typed answer in that shape: one address for a list is a list of one, yes for a bool is true. An answer whose shape contradicts the declaration — a string for a list, the string "true" for a bool — is refused, from the terminal and through satz_interview alike, naming the param and the shape to use.

A default is usable when it is not empty and not built from something still open. infra_project_name defaults to "{customer_shortname}-infra-001"; with the short name unanswered that is -infra-001, which is a string and not a default, so the project name blocks — until the short name is typed, when it offers acme-infra-001. Ask in the pack's order and the derived names arrive as offers.

The gate

Every applicable question must be answered before the estate touches an organisation. bootstrap and transpile --apply refuse while one is open, naming it; bootstrap --dry-run and transpile --plan warn instead. An estate can be transpiled, checked and revised any number of times; the gate is on the step that changes the organisation.

bootstrap refused: 3 question(s) unanswered — customer_id (needs a value),
billing_account_infra (needs a value), default_region. Every question must be
answered before the estate touches an organisation. `satz questions C0example.satz
--unanswered --format text --out -` lists them with their defaults; write the answer (or the default) into
the estate's params.

summary.complete in the JSON report is the same boolean. Bare satz plan and satz apply are not gated: they run the tool in hcl_dir and know no estate.

A pack's notice is the second gate. A pack can name one command to run once it is switched on — the CIS org-policy packs name satz adopt (§6.15). The interview prints it when a yes switches the pack on, satz_interview returns it in notices, and the estate acknowledges it by binding the notice's param true — over MCP, as an answer to that param. Until then the compile warns, and transpile --apply and bootstrap refuse while a severity = error notice is open.

The path: day-0 params, then the map, then the packs

An interview follows the estate file's order, and the skeleton is written so that order is a path:

  1. presets/estate-core.satz — the params init writes, each with its question. Seven have no possible default and block until typed; the rest offer one, the two derived names once the short name is in. workload_folder_name offers "", the organisation — its question declares what an empty answer means (empty), so Enter and --accept-defaults take it. Answering it also writes the section that publishes workload_folder, as init does; an answer whose form differs from the section the estate already carries is refused, because the projects' folders sit under it. The pack emits nothing. An estate init wrote with its flags has every one of them answered; init --interview switches the pack on and asks the rest, and an estate written without --interview carries the line commented out — satz interview offers to switch it on, because the pack also declares the core exports every interface carries.
  2. presets/estate-map.satz — which packs make up the estate, as questions. The first is the CIS baseline (use_cis_baseline, on by default): the estate exists for its thirty organisation policies, and it is asked because they reach the organisation in one apply. Then the security-group model (S1, or S2 with a separate network-admins group) as a question oneof, and one boolean per optional pack, each with what the pack is for and what turning it off later destroys. Four more are on by default — the audit-log archive, the central CIS alerts, the billing permissions, the essential contact. The rest are off: budget, SCC enablement and the packs that carry its findings, the security-audit account, Defender, Sentinel and its two log paths (which follow the Sentinel answer), the verification runner, the exemption tag, the billing export, the shared network, the project CIS alerts and the interface change notice. The map declares the choices and offers each pack with an offers entry; the estate carries one use … when line per choice.
  3. Every pack a choice switched on, with its own questions: the CIS baseline's allowed resource locations and policy members and one switch per opt-in extension and per dry-run twin, the group names of the chosen model, the archive's project and retention, the alert mailbox, and so on. Switch a choice and the rest of the interview re-shapes; the report re-reads after every answer.
use "presets/estate-core.satz"
use "presets/estate-map.satz"

use "presets/cis/CIS-GCP-Foundation-4.0.satz" when use_cis_baseline
use "presets/cis/cmek.satz" when cis_cmek_required
…
use "presets/security-group-models/s1-security-groups.satz" when security_model_s1
use "presets/security-group-models/s2-security-groups.satz" when security_model_s2
use "presets/billing-account-permissions.satz" when use_billing_permissions
use "presets/monitoring/organization-audit-logsink.satz" when use_audit_logsink
use "presets/monitoring/organization-cis-log-alerts-central.satz" when use_central_alerts
…
use "presets/scc/scc-findings-mail.satz" when use_scc_findings_mail
use "presets/integrations/microsoft-sentinel.satz" when use_sentinel
…

google_essential_contacts_contact {
  use "presets/essential-contacts-organization.satz" when use_essential_contacts
}

google_folder {
  infra_folder {
    display_name = infra_folder_name
    google_project { infra { … } }
  }
}

Every line stands at the top level, in the order the map offers the packs, bar a pack that is a bare list of labelled bodies: its line is the content of the map of its type. The folder holds the estate's own resources and takes no use; a pack that creates a project names the folder it is created in with a param of its own, which satz init binds for the audit archive.

Why the lines live in the estate and not in the map — and what stays hand-wired (Defender's plan fragments, the MSP-hosted runner, the per-project alert pack) — is ADR 0007. The lines the skeleton writes come from presets/pack-graph.json, the pack graph that ships with the presets, and a test keeps the map's params and the skeleton's lines equal. With no graph in presets_dir, --create writes the skeleton without pack lines and says so; satz get-presets then satz merge-presets write them.

At the terminal: satz interview

satz interview satz/new-customer.satz --create

--create writes the estate first when it does not exist: an empty params {}, the same day-0 resources init writes — the folder, the project, the state bucket, the IaC group and service account — and every pack's use line commented out, under the phase that has to be finished before that pack can go in. Every line stands at the top level but the essential contact's, whose block is the infrastructure folder; Sentinel, its two log paths and the findings mail read their params, so their lines follow the folder: a param is known from the line that declares it on, and a line above the folder that reads one stops the compile with unknown param once it is uncommented.

That is why a fresh estate asks eighteen questions and not fifty-seven: day 0 is the scaffold alone. Bootstrap it, apply it, satz migrate --mode cloud so the state and the identity move to the service account, and only then does a pack go in — one at a time, each with its own plan. Answering a pack's question true switches its line on — uncommented, or written where the pack graph places it; satz add-pack does the same by the pack's gate or path. Both refuse while a pack it needs is off, naming it, and both refuse a commented line that stands inside a folder's or a project's body, naming the move to the top level; an answer that would leave an estate satz refuses is refused and nothing is written; satz merge-presets writes the line for a pack the library has gained since; and the compile reports a question this estate has answered true whose line is still commented or missing, so they never drift apart. An answer is a binding in the estate's own params {}; a default in the map or in a pack is what the library proposes, so a skeleton that has answered nothing compiles clean at every validation level. satz packs lists every pack with its answer, the library's default, its line and what it needs.

An estate init wrote without --interview has use "presets/estate-core.satz" commented out. The run says so and asks whether to switch it on: Enter, --accept-defaults and an input that has ended switch it on, n leaves it, and the run prints what it did either way. While the library is not in presets/ the line stays and the run names satz get-presets.

Then the interview:

18 open question(s): 9 have a default, 9 need a value.
Accept all defaults now and answer only those 9? [Y/n] — n goes through every question
> y
  accepted 7 default(s).

── estate_core ──
The questions every estate has to answer on day 0, with the params they answer.

customer_id — The Google Workspace / Cloud Identity directory customer id (C0…)
  Every group in the estate is created under this customer. There is no changing it afterwards; a different id is a different estate.
  changing it later: recreate · blast high  ⚠ one-way
  no default — a value is needed
  > C0example
  ✓ customer_id = "C0example"
…
customer_shortname — A short name for this customer — lowercase, no spaces
  > acme
  ✓ customer_shortname = "acme"

infra_project_name — The infrastructure project id
  A project id is immutable. Changing it creates a second project and abandons the first, with the state bucket inside it.
  changing it later: recreate · blast high  ⚠ one-way
  [acme-infra-001] >
  ✓ infra_project_name = "acme-infra-001"

It ends with where the estate stands, what is still open, and — once customer_id is bound — the name init would have given the file, so the interview can start under any name and the file be renamed at the end (git mv, and the estate line).

The client is line-mode: it reads stdin, so a piped run drives it in the smoke matrix. satz calls no model; the human decides every answer.

Through an agent: satz_interview

The MCP tool returns the interview's report and can write the estate and the answers. Its arguments:

argument
estate the file; omit for the open estate
filter unanswered (default) — the worklist; all — every question with its state
create write the estate first if it does not exist, as --create does. Needs write
answers {subject: value} to write before reporting — a param's value, or for a oneof the chosen option's param name — or none for a choice that is not required. Each must name a question the estate asks; one refused answer means nothing is written. Needs write
accept_defaults also write every default the report offers. Needs write

The report's 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.

The loop an agent runs:

  1. satz_interview {create: true} on a new name → every open question of the path — the day-0 ones, the baseline's and the packs the defaults switch on — eighteen, nine of them blocking until their inputs land, each with pack_description, prompt, why, reversal, blast, and default where one is usable.
  2. Ask the human, in whatever order and words fit the conversation. Offer the defaults as defaults — "the project will be called acme-infra-001 unless you say otherwise" — and never invent an answer to a blocking question or a one-way door.
  3. Write what was decided: satz_interview {answers: {customer_shortname: "acme", security_model: "security_model_s2"}, accept_defaults: true}. The response is the report as it now stands — fewer open, and the derived defaults resolved.
  4. Repeat until summary.complete. Then satz_transpile_check, and hand over.

An agent with a filesystem may write the params itself instead; answers exists so a client with only MCP can close the loop. Either way the record is the estate file.

The decisions sheet

Before an organisation is touched, the customer sees what was decided:

satz questions C0example.satz --format markdown --out decisions.md

One table per pack, opened by the pack's description: the question, the answer the estate carries (or the default it would accept, or needs a value), and what changing it later costs.

The catalog a customer keeps

satz questions <estate> --format markdown --out <file> is the decisions sheet (--format pdf typesets it), and it is also the document handed over afterwards: every question grouped by pack, with the answer the estate carries, whether that answer was chosen for this estate or is the same as the pack's default, WHY the question is asked at all — the pack's own sentence — and what changing it later costs in words rather than two enum names.

--format xlsx writes the same catalog as a workbook, which is the one format a customer can fill in and send back: the your answer column is theirs, and needs an answer says which rows are still waiting. It is a format like the others — one invocation, one rendering, at the path --out names.

Over MCP, satz_questions returns the same data as JSON and an agent renders its own — the two file formats are for the human on the other end.

satz questions <estate> --unanswered --format text --out <file> is the same report, reduced to the open questions.

Writing a pack that can be interviewed

A pack is interviewable when every param a customer must decide has a question, and every question says what it costs to be wrong about. The rules, from the language reference:

The first paragraph of the pack's header comment opens the pack's section of the interview. Write it for the customer.

What is not built, and why

The reasoning is ADR 0006.