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 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; 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:
presets/estate-core.satz— the paramsinitwrites, 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_nameoffers"", the organisation — its question declares what an empty answer means (empty), so Enter and--accept-defaultstake it. Answering it also writes the section that publishesworkload_folder, asinitdoes; 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 estateinitwrote with its flags has every one of them answered;init --interviewswitches the pack on and asks the rest, and an estate written without--interviewcarries the line commented out —satz interviewoffers to switch it on, because the pack also declares the core exports every interface carries.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 aquestion 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 anoffersentry; the estate carries oneuse … whenline per choice.- 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"
- The pack's own description opens its section; a question shows its prompt, its
why, and the cost of changing the answer later. A one-way door is marked. - Enter accepts the default in brackets. A
oneoflists its options numbered, the default marked; answer with the number. A choice that is not required also lists0) none, which binds every optionfalse. - Where a pack recommends a different answer from the one on offer, it says so —
the pack recommends: true. Enter still takes the offer, and--accept-defaultsbinds the offer, so a pack can recommend a service that costs money without a bulk run switching it on. skipleaves a question open and moves on;qstops. Every answer is written when it is given, and the next run asks what is still open.--accept-defaultsskips the opening offer and accepts them;--allre-asks answered questions too, with the current answer as the default.- A typed value takes the shape of what it replaces — a boolean stays a boolean, a list is comma-separated. A value with braces is refused: braces interpolate in a Satz string, and a customer's answer is a value, not a template.
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:
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 themblockinguntil their inputs land, each withpack_description,prompt,why,reversal,blast, anddefaultwhere one is usable.- 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
blockingquestion or a one-way door. - 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. - Repeat until
summary.complete. Thensatz_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:
- A question lives in the file that declares its param. Questions are absorbed
after the
use … whenguard, so a question that gates a pack cannot live in the gated pack — put it in the estate, or in a pack the estate always uses (estate-coreis that pack for the day-0 choices). - A choice between packs is two booleans and a
question oneof, with the packs behinduse … when. The pack's= trueoption is the default the interview offers; the estate's binding is the answer. whyis required wherever satz will refuse or warn — on everyrecreateand everyblast = high— because that is the sentence the interview reads out before a one-way door.- A param whose value the pack cannot know defaults to
"", so it isblockingand the interview requires a typed value. - A param derived from another interpolates it:
"{customer. The interview offers it once the input is answered, resolved. Declare the input's question first, so the derived one arrives as an offer rather than a block._shortname}-infra-001" ask_when = <boolean param>hides a question that only applies on one branch.recommendis what the interview shows as advice when it differs from the default; theparamsvalue is what applies.- A command the pack needs run once it is on is a
notice, not a question: it names the command, and the estate acknowledges it by binding the notice's own param. It is no customer decision, so it stays out of the decisions sheet.
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
- A deferred state. "We'll decide later" is not recorded: the question stays open and the gate stays closed, so no apply carries a decision nobody made.
- A lockfile or a derived file. The estate is the answer record; who accepted what, and when, is in git.
- A full-screen client. The line-mode client covers the flow and runs from a pipe; a terminal UI could read the same report.
The reasoning is ADR 0006.