satz workflows

Three walkthroughs, in the order most estates meet them, each a section of this page, and after them three shorter ones: projects beside the estate, how a project — an estate of its own, in HCL or in Satz — reads what the estate publishes and changes what it shares, continuous verification, the compile check and the compliance report run as CI gates, and scanning with Prowler, the second opinion satz joins with its own report. Every command has its own reference section in the README; this page is the order to run them in.

From nothing to applied. The estate is written, the folder, management project and state bucket are bootstrapped, the identity layer is applied as the logged-in user, the state and the identity move to the IaC service account, and only then packs go in, one at a time. Its prerequisites section describes the fresh-organisation case — a super admin creating the identity layer, an Organization Administrator on the organisation — because a new organisation has one principal, the super admin who created it, and Google grants that principal Organization Administrator. That is the strongest case, not what satz needs in general; the last paragraph below says what it needs.

Adopting an organisation that already exists. Folders, projects, groups and policies are already live. satz import discovers them into an estate from a state file or from the organisation itself, the hierarchy is refined by hand, satz transpile and tofu plan hold the result against what is live, and satz adopt resolves the ids of what exists so the plan reads no changes rather than replace. Once it does, the infrastructure is changed through the estate from then on; nothing is recreated on the way.

Keeping presets current. The loop that runs for the life of an estate. check-presets reports which packs are behind upstream, edited locally or changed only in what they ask; get-presets installs what is missing and refreshes what the estate does not use; merge-presets reconciles the rest — a pack behind upstream is refreshed, one you edited becomes an X.local.satz fork with the delta beside it in X.diff.satz, and the estate is repointed. The filename suffix says who owns each file.

satz studio is the desktop app beside satz. It drives the same binary and the same estate files, so every step below can be taken either way, and the diagrams show both: the commands in the terminal, and the views in the app. What the app does not run itself — bootstrap and apply, which create as the human and ask for approval — it hands to the terminal as a command line to copy.

What satz needs, and where. satz owns no credential: it runs as the logged-in user's Application Default Credentials until the switch to the service account, and as that account afterwards. Before bootstrap creates anything it tests the permissions it needs on the scope root — the organisation, or a folder — and on the billing account, and names what is missing. With a folder as the scope root (customer_organization_id = "folders/<id>") the estate installs under that folder: the permissions are tested there, organisation-root operations are skipped, and an operator granted on the folder is not asked for organisation admin. What satz needs is the right to do its work where its root is — create the folder and the project, link billing — and nothing above it. The one exception is Google's: roles/orgpolicy.policyAdmin is granted at organisation level only, so an estate that declares org policies on its folder needs that grant from an organisation administrator before its first apply. The details, including what bootstrap self-grants and when it stops instead, are under Bootstrap the organisation.


From nothing to applied

Five steps, in this order. Each one is a section below.

  1. Write the estate — satz init with the day-0 values, or satz interview … --create to be asked for them one at a time. Either way the file carries the same commented pack menu, so a pack is added by uncommenting its line, by answering its question, or by satz merge-presets writing the line for a pack the library gained since. The menu is written from presets/pack-graph.json, the pack graph that ships with the presets; with no graph in presets_dir the file is written without pack lines, and satz get-presets then satz merge-presets write the whole menu where it goes. A pack scoped to a block — the audit logsink and the CIS log alerts to the infrastructure folder, essential contacts to its own resource map — has its line written inside that block, where its resources belong. A pack that reads a param of one of those two logging packs — Sentinel, which defaults its project to the logsink's, its two log paths, and the findings mail, which defaults its mailbox to the central alerts' — has its line written after the folder, because a param is known from the line that declares it on.

  2. Bootstrap the organisation — satz bootstrap, which creates the folder, the management project, the billing link, the foundation APIs and the state bucket, then transpiles and imports what it made.

  3. Apply as yourself — satz transpile, then plan and apply. This is still deployment_mode = "local": the run authenticates as the logged-in user's Application Default Credentials, and it creates the identity layer — the groups, the IaC service account and its roles.

  4. Switch to the service account — satz migrate … --mode cloud, which moves the state into the bucket and makes every later command impersonate the IaC service account. From here nothing runs as a human.

  5. Then add packs — the CIS baseline and whatever else the estate needs, one at a time, each with its own plan and apply. Presets come after step 4: the day-0 scaffold has to exist and the state has to be in the bucket before a pack creates anything on top of it. Keeping presets current is that loop.

    An estate the interview wrote already lists every pack, each as a commented use line under the phase it can be adopted in — the map first, then the security-group model, then what depends on the groups, and so on. satz add-pack <estate> <gate> adds a pack: it binds the gate, makes the line active, and refuses while a pack it needs is off, naming it; satz remove-pack binds the gate false. Answering that pack's question in satz interview switches its line on the same way, and satz packs shows every pack with its answer, its line and what it needs. satz merge-presets writes the line for a pack the library has gained since the estate was written, so the list stays the library's rather than one person's memory of it. A question this estate has answered true whose line is still commented — or missing — is reported at every compile, because otherwise the answer is bound and nothing emits it. The answer is the estate's own params {} binding: a default in the map or in a pack is the library's proposal, so a day-0 skeleton — every line commented, nothing answered — compiles clean at validation_level = "error", and satz packs is where the proposals are read.

    A pack can name one command to run once it is on: the CIS org-policy packs name satz adopt <estate> --execute --import, because Google sets some of their policies on every new organisation and an apply that creates a policy that exists stops on 409 POLICY_ALREADY_EXISTS. satz prints that notice when the pack goes on, the compile warns at its use line, and transpile --apply and bootstrap refuse until the estate binds the notice's param true — which adopt --execute --import does itself when the run covers every type and nothing is left unresolved.

STEPS 1–3 · AS THE LOGGED-IN USER (ADC) deployment_mode = local · the state is a file in hcl/ STEPS 4–5 · AS THE IAC SERVICE ACCOUNT deployment_mode = cloud · the state is in the bucket 1 · write the estate asked one at a time, or given 2 · bootstrap folder, project, bucket 3 · apply as yourself the identity layer is created 4 · switch to the SA state and identity move 5 · add packs one pack per plan and apply SATZ in the terminal satz init … the day-0 values as flags — or — satz interview x.satz --create asks the 16 questions one at a time; every pack line is written, commented, under its phase satz bootstrap x.satz pre-flight: the permissions on the scope root and the billing account, first then: folder · project · billing link · APIs · state bucket; transpile + import … --dry-run the same, read-only satz transpile x.satz satz plan satz apply as your own credentials; this apply creates the identity layer: the groups, the IaC service account and its named roles satz migrate x.satz --mode cloud deployment_mode = cloud, tofu init -migrate-state: the state moves into the bucket; provider and backend impersonate the SA satz plan → No changes satz whoami x.satz names the account it ran as uncomment a pack's use line, or answer its question: satz interview x.satz satz merge-presets writes the line for a pack the library gained since satz transpile x.satz satz plan · satz apply one pack per apply, as the service account SATZ STUDIO the desktop app — it drives the same satz binary; what it does not run itself, it hands to the terminal the skeleton is written in the terminal, then: Estates open the estate's folder Interview the 16 day-0 questions one at a time — why, reversal, blast — accept the defaults in one click; satz's own writer binds each answer Commands → bootstrap the command line with its arguments, to copy or to open in the terminal bootstrap never runs inside the app: it creates as the human, after its pre-flight Commands → transpile, plan run in the app, the log streamed as it comes Commands → apply in the terminal, with its approval prompt Diagnostics the compile's findings, at the file and line they name in the terminal: satz migrate x.satz --mode cloud top bar · runs-as chip the service account, from here on: the app reads it through satz_whoami on every reload Map Enable a pack · its switches and oneof · Absent → merge- presets writes its line Interview the questions a pack brings Commands → transpile, plan in the app; apply in the terminal, one pack at a time Every step ends with a plan that reads No changes before the next begins. Studio needs satz on the PATH at its minimum version; an older one is refused.
The five steps of an estate's life, each as satz commands in the terminal and as satz studio: write the estate, bootstrap, apply as yourself, switch to the service account, add packs one at a time; steps 1–3 run as the logged-in user with a local state, steps 4–5 as the IaC service account with the state in the bucket

Prerequisites

The executing user needs:

Authenticate, then write the estate and the folder structure around it:

gcloud auth application-default login

satz init \
  --customer-id "C01234567" \
  --customer-shortname "example-org" \
  --billing-account-infra "A12345-B67890-C12345" \
  --customer-domain "example.com" \
  --customer-organization-id "123456789012" \
  --iac-user "admin@example.com"

Without the flags, satz interview satz/<name>.satz --create writes the estate and asks for the same day-0 values one question at a time, offering the derived ones as defaults; an agent does the same over MCP. Both end at the file init would have written, and satz interview describes the rules they share: an answer is a param the estate binds, and nothing below runs while one is missing.

Bootstrap the organisation

bootstrap refuses while any question the estate's packs declare is unanswered, and while a pack's severity = error notice is unacknowledged; --dry-run compiles, prints it and goes on.

Before it asks for a credential, bootstrap checks the params it is about to use: customer_shortname, billing_account_infra, infra_project_name and infra_bucket_name are present — each is the estate's own value, and the only default is the one estate-core declares — the organisation id is a number, the billing account reads XXXXXX-XXXXXX-XXXXXX, and the project id and bucket name are shaped the way Google accepts them. A failure names the param and the flag that sets it, and nothing is called, so an empty value never reaches a URL. Then, before any permission is tested, the organisation itself is resolved: one that is not visible to the caller is reported as that, with the ones that are listed, rather than as a wall of missing permissions. Where the estate binds customer_id as well, the two are cross-checked, so an organisation belonging to a different directory customer is named before anything is created. The state bucket is created in default_region; an estate that binds none gets europe-west3, the default presets/estate-core.satz declares, and bootstrap prints the line default_region not set — using europe-west3, the documented default.

bootstrap creates the day-0 infrastructure — the infrastructure folder, the management project, the billing link, the foundation APIs (which tofu needs enabled before its first run) and the state bucket — then runs transpile, init and the first imports, so what it created is under management from the start.

satz bootstrap C0example.satz

Pre-flight. Before anything is created, bootstrap verifies the ADC identity against first_admin and tests the required PERMISSIONS — never roles — with testIamPermissions:

Where Permission Supplied by
scope root resourcemanager.folders.create (only when infra_folder_name is set) roles/resourcemanager.folderAdmin
scope root resourcemanager.projects.create roles/resourcemanager.projectCreator
scope root orgpolicy.policies.create (the estate's policies, at first apply) roles/orgpolicy.policyAdmin
billing account billing.resourceAssociations.create roles/billing.user

Folder-scoped installs. Set customer_organization_id = "folders/<id>" and the estate installs under that folder: permissions are tested there, and org-root operations are skipped, so an operator granted on the folder is not asked for organization admin. Google grants roles/orgpolicy.policyAdmin only at organization level: on a folder scope a missing orgpolicy.policies.create is reported as advisory, and folder-level org policies need an organization-level grant before their first apply.

Dry run. satz bootstrap <estate> --dry-run is read-only: it prints the plan, verifies the identity and runs the same pre-flight (a would-be self-grant is reported, not executed). Without credentials the plan still prints and the skipped pre-flight is named (pre-flight: SKIPPED).

What bootstrap does NOT do: it creates no service account and grants no IAM beyond the self-grant above — the IaC service account and its grants are declared in the estate and come into being on the first tofu apply.

What follows. bootstrap ends by naming the next commands with the estate's own values, in this order: satz update-prerequisites <estate>; satz transpile <estate> --plan, then --apply, as you in local mode, which creates the groups, the IaC service account and its roles; satz migrate <estate> --mode cloud, which moves the state into the bucket and checks the service account for Groups Admin — a Workspace role, not an IAM grant. It asks the service account first, as itself, and stops there when it holds the role; only when it does not is your login asked, and when your login holds the role migrate assigns it to the service account through the Admin SDK, given a login with the role-management scope. Otherwise it says what is missing and names the admin-console path; and satz whoami <estate> followed by a plan that must read "No changes". migrate, get-presets and adopt --execute end with their next command the same way.

Credential line. Every live command prints one line before its first API call — credentials: <identity> (user ADC | impersonated service account | service account key), quota project <p> — so a wrong per-customer login shows before the first call instead of as a later 403. satz whoami is the explicit check (--offline for the file-only view; a user ADC file stores no identity, so the online form resolves it via token introspection). satz whoami <estate> answers the other question — the identity that estate's live commands actually run as. On a cloud-mode estate that is its IaC service account, and the line names the relation: runs as: svc-iac-…@… — impersonated by you@…. A local-mode estate — the state after bootstrap, because the first apply creates the account — runs as you, and the line names the account it declares and the command that switches to it: runs as: you@… — local mode; satz migrate <estate> --mode cloud makes every run impersonate svc-iac-…@…. Without an estate the line says none was given. Both halves print together, because a live command uses both, and online it CHECKS them: one generateAccessToken (token discarded) for whether this credential may become that account, and one projects.get for whether the quota project is reachable. Given an estate that compiles, it also tests the permissions the estate's resource types need (testIamPermissions on the organization, the infra project and the billing account) and names each missing one with the role that carries it. --offline reads the estate file alone and says the checks were not made rather than implying they passed.

Quota project. Set the ADC's quota project to a project the caller can reach, normally the infra project:

gcloud auth application-default set-quota-project <infra_project_name>

A quota project the caller cannot see — for example a typo — does not work: commands that only print it accept it, and every API call then fails with UserProjectInvalid, or report-compliance with "live inventory unavailable". Live commands check it once before the work and refuse with the project named.

Impersonation. On a deployment_mode = "cloud" estate, every live command impersonates the estate's IaC service account ({svc_iac_account}@{infra_project_name}.iam.gserviceaccount.com) — exactly the identity tofu applies with — so the human needs no org-wide read roles, only roles/iam.serviceAccountTokenCreator on the SA (normally via membership in svc-iac-users). --no-impersonate opts out; bootstrap never impersonates (day 0, the SA may not exist yet); an ADC that already impersonates is used as-is. The credential line names the SA the calls actually run as. An estate whose params do not parse, whose deployment_mode is neither local nor cloud, or whose cloud mode has no value for svc_iac_account or infra_project_name, names no identity: whoami, migrate and every live command refuse it with the reason, and none runs as the logged-in user instead.

Greenfield: a tenant with no organization yet. Google creates the Organization resource for a Workspace/Cloud Identity domain when a NEW Google Cloud user signs in to the console and accepts the terms, or when an EXISTING user creates their first project or billing account (documented). A customer new to Google Cloud whose admin has signed in to the console and accepted the terms therefore already has an organization: satz init derives its id, and plain satz bootstrap is the path. --greenfield is for a tenant where nobody has signed in yet, and uses the second trigger:

  1. satz init --customer-id <C0…> — derives every derivable init value from the ADC alone (identity → first_admin + customer_domain, organizations:search → org id + directory customer id, billingAccounts.list → the single open account; explicit flags always win, nothing is guessed). With no organization visible, the estate is written with an empty customer_organization_id.
  2. satz bootstrap <estate> --greenfield — creates the infra project WITHOUT a parent (the trigger), polls organizations:search until the new organization appears (matched by its directoryCustomerId, never "the first org"), moves the project under it, writes the id back into the estate, and continues with the normal pre-flight and build. If the organization never appears (the ADC user has not accepted the console terms), the timeout names the one-time console sign-in as the fallback.

An estate whose customer_organization_id is empty fails with exactly this guidance instead of a bare "missing org id".

Transpile, plan, apply

Bootstrap already transpiled and initialised. After any later edit to the estate, compile it again and read the plan:

satz transpile C0example.satz

cd hcl/
tofu plan
tofu apply

The first apply creates the identity layer: the Cloud Identity groups, their IAM roles (Token Creator among them) and the rest of the management project.

tofu applies hcl/ as satz wrote it, with one exception that needs an argument: an org policy the state holds with rules while the estate declares it reset = true. The API refuses to switch such a policy to reset in place (400 Cannot set PolicyRules if reset is true), so the apply has to replace it. satz plan and satz apply add the -replace; for an apply that does not run through satz, main.tf names it in a comment above each policy it declares reset:

# Declared reset: while the state holds its rules, the API refuses the in-place update (Cannot set PolicyRules if reset is true).
# satz plan and satz apply add the replace; a bare apply needs: tofu apply -replace=google_org_policy_policy.iam_disableServiceAccountKeyUpload_superseded
resource "google_org_policy_policy" "iam_disableServiceAccountKeyUpload_superseded" {

A policy the state holds without rules, or does not hold yet, needs no -replace.

A pack added later can emit a resource type the IaC service account holds no role for. The compile names the role, and satz update-prerequisites C0example.satz writes it into the estate's grant list. The apply then creates the grant and the resources that need it in one pass; a resource that meets a permission error on the role just granted is created by running the apply again once the grant has taken effect, which takes up to a few minutes.

The API preflight

The estate's google provider carries user_project_override = true with billing_project = <infrastructure project>, so Google bills every call it makes to that project and wants the API enabled there; a project's own alias bills to that project. tofu apply refreshes every resource in state before it creates anything, and an API the estate declares as a google_project_service but the billed project has off stops that refresh — before the declaration that would enable it is created.

satz plan and satz apply enable them first. They read the emitted providers.tf for every project a provider bills to and the service account the default provider impersonates, read main.tf for the services declared on each of those projects, ask Service Usage which are off and switch those on, as the estate's IaC service account — the identity the estate grants roles/serviceusage.serviceUsageAdmin — through the default provider's project: the Service Usage calls bill to the infrastructure project, so a project with serviceusage.googleapis.com off has its APIs switched on as well. satz transpile --plan and --apply do the same. A project node's project is checked when the state holds its google_project or an import block adopts it; otherwise the run creates it, its services come after it, and the preflight says so and goes on. The default provider's project is always checked. One line per project:

APIs on corp-data-001: not checked — google_project.data is neither in the state nor adopted by an import block, so this run creates it and its 2 declared API(s) after it
APIs on corp-infra-001: 14 declared, all enabled

and a change names every API:

APIs on corp-infra-001: 14 declared, 2 off — enabling them, because the refresh is billed to this project and runs before anything is created
  enabled cloudasset.googleapis.com
  enabled essentialcontacts.googleapis.com

The enable is a live change outside tofu's state. The declared google_project_service records an API that is now already on and its create is idempotent, so the plan that follows shows no change for it.

Where the enable cannot succeed — the identity holds no serviceusage.services.enable, the Service Usage API is itself off on the billed project, an org policy forbids it — the run stops before tofu and prints the command that does it by hand:

APIs on corp-infra-001: satz could not enable them: 403 PERMISSION_DENIED …
  cloudasset.googleapis.com
  essentialcontacts.googleapis.com
enable them and run this again:
  gcloud services enable cloudasset.googleapis.com essentialcontacts.googleapis.com --project corp-infra-001

satz --no-api-preflight plan skips it: satz asks Service Usage nothing and enables nothing. update-prerequisites writes the declaration into the estate and enables nothing either; it prints the same gcloud services enable line, one per project, for the APIs it adds. The reasoning is in ADR 0036 and, for the projects beside the infrastructure project, ADR 0072.

Switch to the service account, and deploy as it

Steps 1–3 ran as the logged-in user. This is where that ends: the state moves into the GCS bucket and every later command impersonates the estate's IaC service account.

DEPLOYMENT_MODE = LOCAL steps 1–3: as yourself the logged-in user Application Default Credentials, gcloud's satz transpile → hcl/ provider blocks with no impersonation satz plan · satz apply tofu runs as the user; this apply creates the identity layer: the groups, the IaC service account, its named roles state: a file in hcl/ on this machine only satz migrate x.satz --mode cloud deployment_mode = cloud rewritten in the estate tofu init -migrate-state moves the state providers and backend re-emitted with impersonate_ service_account DEPLOYMENT_MODE = CLOUD from step 4: as the service account the logged-in user the same credentials — now only to impersonate the IaC service account svc_iac_account in infra_project_name satz plan · satz apply tofu runs as the service account: every provider block and the gcs backend carry impersonate_service_account state: the GCS bucket read and written as the service account, never as the human satz whoami x.satz prints which of the two a run uses. In satz studio the top bar's runs-as chip shows it; migrate itself runs in the terminal. After the switch, tofu refuses the next command until re-initialised; migrate runs tofu init -reconfigure itself.
Local and cloud side by side: as yourself with a local state file in steps 1–3, then satz migrate --mode cloud moves the state into the bucket and makes provider and backend impersonate the IaC service account, which runs everything from step 4 on

Switch the state to the GCS bucket and the identity to impersonation:

satz migrate C0example.satz --mode cloud

migrate rewrites the estate's deployment_mode, switches to service-account impersonation and runs tofu init -migrate-state. An estate without svc_iac_account or infra_project_name is refused before the file is touched, naming the param: the service account is the one those two name. Impersonation applies to both halves of the run: every provider block gets impersonate_service_account, and so does the gcs backend, so the state bucket is read and written as the service account rather than as the logged-in user.

When the emitted backend changes, tofu refuses the next command until it is re-initialised: run tofu init -reconfigure once in hcl/. migrate re-initialises by itself.

Then check that the service account can run the plan — this is the first deploy that authenticates as the service account rather than as a person, which is what the whole sequence is for:

cd hcl/
tofu plan

A plan that reads No changes here means the estate, the state and the organisation agree, and the estate is ready for its first pack. satz whoami C0example.satz prints the identity the run used, which from now on is the service account, impersonated by the human who typed the command: runs as: svc-iac-…@… — impersonated by you@….

The params init writes

The same params, each with its question, are presets/estate-core.satz — what satz interview asks when there are no flags. An estate init wrote binds all of them and is complete; one the interview wrote is complete when it says so.

Param Default Description
infra_folder_name "Infrastructure" Display name for the top-level folder. Leave "" to create the project in the root.
infra_project_name "" The unique id for the management (IaC) project.
infra_bucket_name "" The GCS bucket for Terraform state.
customer_id (from CLI) The Workspace customer id (e.g. C01234567).
customer_organization_id "123456789012" The numeric Google Cloud organization id.
customer_domain "" The customer's primary domain (e.g. example.com).
first_admin (from --iac-user) Local part of the first admin's address; members are built as user:{first_admin}@{customer_domain}.
customer_longname "" The full legal name of the customer entity.
customer_shortname "" A unique slug for the customer. Names that must be globally unique derive from it.
svc_iac_account "svc-iac-001" The primary IaC service account.
svc_iac_users_group "svc-iac-users" The Cloud Identity group for IaC administrators.
billing_account_infra "" The billing account id (e.g. 012345-6789AB-CDEF01).
deployment_engine "tofu" The IaC tool: tofu or terraform.
deployment_mode "local" local for day 0 (user ADC); cloud for day 1+ (impersonation). Switched by satz migrate.
default_region "europe-west3" Default region for regional resources.
default_zone "europe-west3-a" Default zone for zonal resources.
workload_folder_name "" (from --workload-folder-name) The folder where the customer's and the projects' folders live; "" is the organisation, and nothing is created for it.
compliance_frameworks ["cis-gcp-5.0"] The frameworks this customer is HELD TO, as catalog ids from presets/catalogs/ (cis-gcp-4.0, cis-gcp-5.0, iso27001-2022) — a contract, an auditor, a regulator. Not what the estate claims, which comes from its packs. A value naming no catalog is refused by the compile.

Tear the estate down

The provider protects the two nodes the tree is built from: a project's deletion_policy is PREVENT and a folder's deletion_protection is true unless the estate says otherwise, and satz writes neither attribute by itself. A tofu destroy is therefore refused — first for the project, then, once that is settled, for the folder.

Both are ordinary attributes. Declare them, apply each one on its own, then destroy:

# 1. the project: deletion_policy = "DELETE" in every google_project body
satz transpile C0example.satz
satz apply --config . -target=google_project.infra

# 2. the folder: deletion_protection = false in every google_folder body
satz transpile C0example.satz
satz apply --config . -target=google_folder.shared

# 3. the destroy the two edits allow
satz apply --config . -destroy
google_folder {
  shared {
    display_name        = "Shared"
    deletion_protection = false
  }
}

google_project {
  infra {
    project_id      = "acme-infra-001"
    deletion_policy = "DELETE"
  }
}

Each attribute has to be in the state before the destroy reads it, which is what the two targeted applies are for: a destroy run against a state that still holds the protection is refused whatever the configuration says. The edits stay in the estate — declared in git, visible in a plan, and the reason a project is destroyable is the line that made it so.


Adopting an organisation that already exists

Discover

Capture what is there. From an existing Terraform/OpenTofu state:

tofu show -json > state.json
satz import state.json -o migration-discovery.satz

Every estate is bound to an organisation, and a state carries its number only where a resource names it (organizations/<n>, org_id) or a top-level folder hangs under it. A state of folders and projects nested under a folder outside it names none, and the import is refused with nothing written — name it:

satz import state.json --organization 123456789012 -o migration-discovery.satz

Or straight from Google Cloud, with no state at all:

satz import organizations/123456789012 -o migration-discovery.satz

The sweep reads the organisation with your own Application Default Credentials, which need roles/cloudasset.viewer on it. On an organisation satz set up, that role is the estate's IaC service account's: name the estate with --as and the sweep reads as that account, the one tofu applies with.

satz import organizations/123456789012 --as C0example.satz -o migration-discovery.satz

The run prints as whom it sweeps before the first request, and a refusal repeats it. --as needs an estate that impersonates a service account: a local-mode estate impersonates none, and --no-impersonate keeps any estate off its account, so both are refused — the sweep would read as your own credentials, which the bare form does. --as and --into sweep only the organisation the estate is bound to (customer_organization_id) or a folder or project inside it; a folder or project is walked up through Resource Manager as the estate's identity, and a scope in another organisation, or one whose organisation cannot be read, is refused before the sweep.

From a directory of .tf files the organisation is the one the configuration names — a literal organizations/<n> parent, an org_id, an org policy's parent. A variable the configuration uses as the organisation and declares with no default — org_id = var.org_id, the form vendor-generated onboarding scripts ask for — binds to the organisation --organization <n> names, and its param is written as customer_organization_id. A configuration that names none, and every --wrap-all import, which translates nothing, is refused with nothing written until --organization <n> names it:

satz import ./terraform --wrap-all --organization 123456789012 -o migration-discovery.satz

The resource types marked import: true in presets/import-config.yaml are the default set. --all takes every type the source can deliver instead: from a state file every row, live every row with an asset_type. --only narrows either set, and --exclude leaves types out (--all --exclude "google_*_iam_member"). The table is the provider's resource types at its provider_version — google and google-beta, 1280 rows: 449 with their Cloud Asset Inventory name (derived from the type name, checked against Google's list, presets/cai-asset-types.txt, and asked of ListAssets), 439 that Cloud Asset does not serve as assets (IAM members, org-policy v1 shapes, the types ListAssets refuses; state shape only), 392 marked TODO/UNKNOWN (Cloud Asset does not inventory them, or the name could not be derived — scripts/update_import_config.py prints what it tried). A copy of the table with your own import: flags, passed with --import-config, is the repeatable form. A grant one principal holds on two folders or two projects is refused, because the map form emits one address per member and role; --on-collision counter writes the second and later as labelled resources with a running number, and says which.

Google retires asset types, and presets/cai-asset-types.txt is a snapshot of what it served when the file was refreshed. A type the API no longer serves makes it refuse the whole request of a hundred types it stands in, naming none of them: satz asks the request again in halves until the refusal is down to that one type, leaves it out and fetches the other 99. The run ends by naming each such type with the rows that asked for it, so what the estate is short of is on the screen; refresh the table or leave the rows out with --exclude. A scope the credential may not read, a connection that broke, and a sweep in which every type asked for was refused — a scope ListAssets cannot read, however few types were asked — end the run with nothing written.

A live resource whose provider block would not plan is never written: a required attribute the asset data lacks is derived where it can be (parent, org_id/folder/project from the asset path, a service account's account_id from its email) and the resource is otherwise skipped with the attribute named. An attribute the estate says by where the resource STANDS — the enclosing project, folder or organization, and an org policy's parent — is no hole: the asset data never carries it and the emitter writes it from the node the resource is written under.

What is not there to import is left out and listed as not live: a folder or project that is no longer ACTIVE (Cloud Asset lists a deleted one for 30 days), everything inside one — its grants, its log buckets, a resource no row maps, which --generate-unmapped is not asked for either — and a service that is not enabled. Cloud Asset goes on listing a switched-off service as ENABLED, so every service the sweep found is confirmed with Service Usage per project; one it does not report enabled is left out, and a project whose services cannot be read is named with its services written as Cloud Asset lists them.

A resource written inside a project is served by that project's own provider, which bills its calls to the project, so the project needs the resource's API on. The same Service Usage request asks for every API the resources inside the project need; one the project has off is added to its project_service list with the import id it has once it is on, and the run names each:

import: 1 API(s) added to a project's `project_service` list — the project has it off and resources imported into it need it; `satz plan` and `satz apply` switch it on through the infrastructure project before `tofu` starts:
  logging.googleapis.com on acme-log-001 — needed by google_logging_project_bucket_config

satz plan switches it on before tofu refreshes anything (the API preflight), and the plan imports it with the rest. Under --into, an API the estate declares on that project already is left to the estate. A tofu plan run directly needs the gcloud services enable <api> --project <project> first.

Given an estate (--into, --as), every read the run makes — the sweep, Service Usage, Cloud Billing, Resource Manager, the lookups --into resolves the estate with — is billed to the estate's infra_project_name, and the run switches on there any of the APIs those reads call that is off (cloudasset, cloudbilling, cloudidentity, cloudresourcemanager, orgpolicy, serviceusage) once the scope is checked and before the sweep. A workload project is never switched on by the import itself. An estate that binds no infra_project_name reads with the caller's quota project, and the run says so.

A subnet's local route, which states a nextHopNetwork no argument of google_compute_route sets, is listed as platform-managed. A type the provider has no import for (importable: false on its row: a service account key) is not swept, and the run names it.

The API's shape is written in the provider's: a wrapper { fingerprint, items } is its items where the attribute is a set (an instance's tags) and a map where it is a map (metadata); a plural list whose singular is a block is that block (networkInterfaces → network_interface, accessConfigs → access_config); an instance's disks are its boot_disk and attached_disk blocks, each naming its disk by source; a disk's type self-link is the type's name and its source image and size are image and size; a DNS zone's visibility is lower case. A log bucket config names its parent the way the provider holds it — projects/<id>, folders/<n>, organizations/<n> — and a reference that names a project by its number (an alert policy's notification channels) names it by its id where the sweep read that project. A disk's architecture is left out and listed: the provider does not read it back on import, and written it forces a replacement. Where the asset's parent does not decide between two rows whose types name none, the provider schema does: a type with a project argument serves a project, one with a parent of its own an organisation or a folder — a project's network firewall policy and a hierarchical one share a Cloud Asset type.

Import ids of live resources are the asset path, with the project named by id (the provider keeps a project NUMBER on import and the declared id would then force a replacement) and a DNS zone by its name, rendered through the import-config row's import_id template where it has one — the same id --generate-unmapped writes and --into subtracts by. An empty string is carried on a required attribute (expiration_policy.ttl = "" is never expires) and dropped on an optional one, which the provider reads as unset. Verified on a test organization with folders, projects, services, buckets, IAM, org policies, org/folder/project log sinks, a service account and an essential contact: tofu plan = every resource imported, nothing added or destroyed.

Refine the estate

The discovered estate compiles as-is and mirrors the live layout in the language's own forms: projects sit in their folders and resources in their projects, folders are labelled by display name, grants are member → roles maps with one line per edge, services are the project's project_service list, an org policy is its bare constraint with a spec { … } block, and the organization is referenced as customer_organization_id wherever its number was written. What the platform owns — the built-in _Default and _Required log sinks on every container, the grants of Google's service agents, the legacy bucket grants, Google-created service accounts — is not in the file; the import lists each group under the skip: pattern of the import-config row that took it, and a copy of the table without that pattern imports it. A subnet's local route is listed as platform-managed, and what is not live — a deleted folder or project, what is inside one, a service that is not enabled — as not live.

The params block is the day-0 vocabulary init writes, bound from what the platform states and what the sweep implies: the ADC gives customer_id, customer_domain, first_admin and a single open billing account; the service account granted organizationAdmin at the organization gives svc_iac_account and infra_project_name, and that project its folder, its versioned bucket and its billing account; the members give svc_iac_users_group; the regional resources give default_region; the leading token of the project and bucket names gives customer_shortname, unless --customer-shortname says. A value a rule chose carries // inferred: with the rule and its evidence; a value nothing states is left out and reported with how to bind it. Every bound literal is referenced wherever the body repeats it, the way the library spells it, so the estate already speaks the packs' vocabulary.

What is left is what only a person decides:

Reconcile

Generate the HCL and hold it against the live organisation:

satz transpile migration-discovery.satz
cd hcl/ && tofu plan

A plan that says replace where it should say no changes means the labels or the resource ids do not line up. Two ways to fix it: declare "import-id" in the estate to bind the existing resource, or tofu state mv to move the existing state onto the new address. satz adopt resolves those ids for you where it can — natural-key lookups for folders, groups, memberships and org policies, IAM grants against the live IAM policy of what they are granted on, import_id/match_on rules for the rest — and never guesses: one candidate resolves, several is ambiguous.

Hand over to satz

When tofu plan shows no changes, or only intended ones, the migration is complete; from then on the infrastructure is changed through the estate.

The IaC service account reaches the adopted folders and projects, hand-made ones included, through the roles it holds at the organization: every folder and project inherits them. satz update-prerequisites <estate> writes each role the adopted resource types need that the estate does not grant it yet into the estate; --report-only names them and writes nothing.


Projects beside the estate

satz builds the organisation: the folders, the infrastructure project, the shared network, the organisation policies. The workloads in it live in projects: estates that depend on parts of this estate's interface, each with its own repository or folder, config, state and pipeline, often maintained by a different team, and written in HCL or in Satz (the term). The estate whose interface they read is the central estate. A project reads what the central estate publishes and never changes its HCL or its state.

What the estate publishes

An export statement (language §6.17) names one value. satz transpile writes each one twice:

An export outside every interface block is a core export, and every interface carries it. An estate that uses presets/estate-core.satz publishes the core exports without writing any: organization_id, customer_domain, customer_shortname, default_region, infra_project_id and iac_service_account. An estate satz init writes also exports infra_folder and workload_folder.

workload_folder is the parent every project's folder takes: organizations/<id>, or folders/<number> of the folder satz init --workload-folder-name <name> declares directly under the organisation. A project creates its folders under it:

resource "google_folder" "payments" {
  display_name = "payments"
  parent       = module.satz.workload_folder
}

init writes the section that publishes it, in the form its flag names, and satz interview writes it when workload_folder_name is answered. The folder block stands at the top level, so its parent is the organisation; to put the workload folder inside another folder — a top-level folder named after the organisation — move the workload_folder { … } block into that folder's block, and the interface's lookup follows the parents. An estate without the section does not publish workload_folder and adds it by hand — for the organisation, with workload_folder_name = "", the one line

export "workload_folder" = "organizations/{customer_organization_id}" description "Where the customer's and the projects' folders live"

and for a folder, with workload_folder_name bound to its display name, the folder itself and its lookup:

google_folder {
  workload_folder {
    display_name = workload_folder_name
  }
}

export "workload_folder" = "${{google_folder.workload_folder.name}}" description "Where the customer's and the projects' folders live"

The compile refuses a name and a section that disagree, at the line to edit: a name with no export "workload_folder" or with the organisation's, and an empty name with a folder's.

Google refuses a second folder of one name under one parent, so a folder the customer already has is imported first: satz adopt <estate> --execute --import finds it by its display name under the organisation and writes its id into the estate.

Every resource of one kind is one export. export "folders" = all google_folder publishes a map keyed by each folder's label in the estate, so a project reads module.satz.folders["payments"] and a folder the estate adds later arrives as a new key:

resource "google_project" "payments" {
  project_id = "acme-payments-001"
  name       = "payments"
  folder_id  = module.satz.folders["payments"]
}

The README lists the keys. private = true in a resource's body keeps it out of every map, out of everything written for a project, and refuses an export that names it; the estate satz init writes marks its state bucket and its IaC service account that way. An estate whose google_storage_bucket.state or google_service_account.provisioner lacks the line publishes it in such a map; add private = true to both before exporting all google_storage_bucket or all google_service_account.

What one project reads goes into an interface block named for the project, in the estate or in a pack; the same interface in two files is one interface:

interface "payments" {
  export "folder"  = "${{google_folder.payments.name}}" description "The payments folder, folders/<number>"
  export "network" = "${{google_compute_network.shared.self_link}}"
}

A set several projects read — the shared network, the DNS zones — is a common interface: declared once, marked interface "<name>" common { … }, or declared by the pack that builds it, which makes it common without the word. The common interfaces are the library every project's folder carries. A project's interface also takes one with use interface, and then its own interface carries those exports beside its own:

interface "network" common {
  export "host_project" = "${{google_project.net_host.project_id}}"
  export "vpc"          = "${{google_compute_network.shared.self_link}}"
}

interface "payments" {
  use interface ["network", "dns"]
  use interface "logging" when want_payments_logging
  export "folder" = "${{google_folder.payments.name}}"
}

A pack that removes or renames an export, or changes the shape of its value, breaks every project that reads it: that is a breaking change, with an entry under ## Breaking changes in presets/CHANGELOG.md, and the release that ships it is a minor one.

The folder a project takes

satz transpile writes interfaces_dir (interfaces, a key of config.toml) whole:

interfaces/
  common/                       the library alone, for a project without an interface of its own
    README.md
    core/      README.md  hcl/  satz/
    network/   README.md  hcl/  satz/
  payments/                     the project "payments" takes this folder whole
    README.md                   its interfaces; the estate, the satz version, the content hash
    payments/  README.md  hcl/  satz/   its own interface: its exports, the core ones, those it uses
    core/      README.md  hcl/  satz/   every common interface, always
    network/   README.md  hcl/  satz/

A project takes its folder whole, interfaces/<project>/, into its own repository or beside its code, and takes it again after every change of the central estate. The folder holds the project's own interface and the whole library, never another project's, so it is one snapshot and nothing in it is picked by hand. An interface the project does not read costs nothing: HCL evaluates a module's lookups only where the configuration sources it, and a Satz estate reads only the files it uses. The folder's README.md names the estate, the satz version that wrote it and a content hash of every other file in it, so two copies are the same when their hashes are.

What changed, and what to do about it. When a transpile changes a value of an interface, the interface's folder gains CHANGES.md beside its README.md: the previous satz/interface.satz on disk against the new one, written as the todo a project follows, in both spellings —

## To do

- [ ] Replace `module.satz.folder` / `${{interface.folder}}` with `…team_folder`: renamed, the same `google_folder.team`.
- [ ] `number` is now looked up (`data.google_project.infra`): your plan needs resourcemanager.projects.get.
- [ ] `folders` lost the key `"old"`: `module.satz.folders["old"]` fails at plan.

## Also changed

- `folders` gained the key `"new"`.

A rename is an output that went and one that came naming the same resources in the same shape; an output gone, a map key lost, an attach point dropped, a value now looked up and a shape that changed are the other todos; a new output, a key or an attach point gained, a value now static, an output that now names other resources and one whose value changed are information. A description edit is no change, and an interface the transpile did not change has no file. One transpile is one step, so nothing accumulates: a project that moves several commits of the central estate at once reads the file's history in the estate's repository. The file is not in the folder's content hash.

Every interface has the same two forms, with the same values:

<name>/README.md travels with both: the snippets above, every value with the interface it comes from, how it is obtained and what it takes. The root module's tofu output names a project's values <project>__<export>, with - written _ (payments__folder).

Onboarding a project, and a project on satz

A project is one entry of projects in presets/project-onboarding.satz:

params {
  projects = [
    { name = "payments" owner_group = "payments-owners@example.com" },
  ]
  project_onboarding_folder = "google_folder.workload_folder.name"
}

use "presets/project-onboarding.satz" when use_project_onboarding

Per entry the pack declares the project's Google project {customer_shortname}-payments-001 under project_onboarding_folder — the workload folder here; empty is the organisation — with its IaC service account (svc-iac-payments) and its state bucket inside it, the grants (the account takes the project's IAM and its services and the bucket, so its estate grants itself the rest with satz update-prerequisites; the owner group reads the project and may become the account), and the project's interface, written once per entry. The compile refuses an entry whose owner_group is not an address <name>@<domain> or whose name is not lowercase letters, digits and - starting with a letter, naming the entry and the value:

each projects by name {
  interface "{each.name}" {
    export "project_id"     = "${{google_project.{each.name}.project_id}}" attach ["google_project_iam_member"] description "The project's Google project"
    export "project_number" = "${{google_project.{each.name}.number}}" description "Its number, looked up"
    export "iac_account"    = "${{google_service_account.{each.name}_iac.email}}" description "The IaC service account the project's estate runs as"
    export "state_bucket"   = "${{google_storage_bucket.{each.name}_state.name}}" description "The bucket the project's estate keeps its state in"
  }
}

The entry is the estate's own, or the project's request: contributes_projects = [ … ] in a small pack of the project's, checked with satz check-request and vendored into the estate by pull request (language §6.17). The pull request is the request's review; the apply creates the project; satz transpile writes interfaces/payments/. Two entries of one name are refused.

A project's natural set is often what stands under its folder: export "team_projects" = all google_project under google_folder.team_a publishes that map, and a Google project added under the folder later arrives as a new key. private google_storage_bucket.logs in the estate keeps one resource — a pack's included — out of every export.

satz interfaces acme.satz --format text --out - lists every export and interface there is to pick from. An interface declared in two files is one interface, so the estate adds to a project's interface with a block of the same name in its own file:

interface "payments" {
  use interface "network"
  export "archive_project_id" = "${{google_project.archive.project_id}}" attach ["google_project_iam_member"]
}

A workload, in HCL or in Satz, that brings its own Google project gets an interface written by hand, interface "billing" { … }, and no entry.

A project on satz starts from that interface. In its own directory:

satz init --project payments --interface <the estate>/interfaces/payments/payments/satz/interface.satz
satz transpile payments.satz

init --project writes config.toml and satz/payments.satz from the interface file and nothing else — no credential is read. It reads project_id, iac_account, state_bucket and the core export default_region (a central estate without estate-core.satz publishes none and is refused), and writes default_region, default_zone and deployment_engine beside these: infra_project_name is the project's Google project, infra_bucket_name its state bucket, svc_iac_account its account, deployment_mode = "cloud", the gcs backend on the bucket, the providers, and the use of the interface file. The estate compiles as the project's own IaC service account, and the project's resources read the central estate as ${{interface.<export>}} — project = "${{interface.project_id}}" on each — and are held to the interface's rules at every compile. The interface's README.md lists what may be attached to; CHANGES.md beside it, after a change of the central estate, says what to do about it.

A team's deployer identity

A team that rolls out many Google projects and folders — with templates of its own, in OpenTofu or in Satz, each with its own state — runs as an account of its own, never as the organisation's IaC account. presets/workload-deployer.satz creates it per entry of workload_deployers: the team's infrastructure Google project, inside it the account svc-deploy-<name> and the state bucket its states live in, and the roles the account needs — folder and project creation, IAM and API enablement on the parent folder it creates under (the workload folder, or the folder the entry names; further parents through workload_deployer_folders), billing user on the billing account so a new project can be linked, and roles/browser on the organisation, which is what the interface's lookups need: the workload folder is looked up by its name among the organisation's folders. The team's group may become the account and nothing else. The role sets are params.

The team's pipeline impersonates the account and keeps its state in the team's bucket. It reads the organisation through the interface folder it took, and never the organisation's state: a looked-up value asks the Google API for the live resource with the team's own credentials (the pack).

Static values and lookups

An export whose value satz knows at compile time — a param, a Google project id satz writes, a service account's email built from its account id — is a literal. The project's plan makes no API call for it.

An export of an attribute only the cloud knows — a folder's folders/<number>, a Google project's number — is a lookup: a data source that reads the resource back by what satz writes on it (a folder by its display name under its parent, a Google project by its id). The lookup runs through the project's own provider and credentials, so the project needs read permission on what it looks up; the README names the permission per lookup. A project without it fails its own plan, and the central estate's state is not involved. An interface holds only the lookups its own values read.

A project written in Satz gets both through interface.tf in its emitted hcl/: locals { interface = { … } } holding every value it read, and the data blocks the looked-up ones need. main.tf reads local.interface.<export>, providers.tf reads local.interface_static.<export> (the values satz knows — a provider cannot wait for a lookup it runs), and neither carries a value of the organisation itself, so that file is the one place the organisation stands in the project's HCL — and the one file that changes when the project is pointed at another organisation.

Writing to shared infrastructure

A project changes something the central estate owns in one of two ways, and never by editing the estate's HCL or state.

Attach — the write lives in the project's own state. The provider has additive resources for joining something another state owns, and the project declares them in its own configuration:

# a Google project of the project's, attached to the estate's shared VPC host project
resource "google_compute_shared_vpc_service_project" "payments" {
  host_project    = module.satz.network_host_project
  service_project = google_project.payments.project_id
}

# the Google project in the estate's service perimeter
resource "google_access_context_manager_service_perimeter_resource" "payments" {
  perimeter_name = module.satz.perimeter_name
  resource       = "projects/${google_project.payments.number}"
}

# a role on the project's folder, one member at a time
resource "google_folder_iam_member" "payments_deployer" {
  folder = module.satz.payments_folder
  role   = "roles/resourcemanager.projectCreator"
  member = "serviceAccount:deployer@acme-payments-001.iam.gserviceaccount.com"
}

The exports network_host_project and perimeter_name stand for what the estate publishes when it has a shared VPC or a perimeter. satz grants with *_iam_member only, which adds a member and removes none, so a project's grant and the estate's live side by side.

An attach point is an export that says which attachments it takes. attach [ … ] after the value names the attachment resource types a project may create against it:

interface "payments" {
  export "network_host_project" = "${{google_project.net_host.project_id}}" attach ["google_compute_shared_vpc_service_project"]
  export "perimeter_name"       = "${{google_access_context_manager_service_perimeter.main.name}}" attach ["google_access_context_manager_service_perimeter_resource"]
  export "payments_folder"      = "${{google_folder.payments.name}}" attach ["google_folder_iam_member"] description "The payments folder"
}

Each interface's README lists its exports and what each takes — a table when one of them is an attach point, prose otherwise: every one is read, an attach point also takes the types it names. The types an export may name, and what each conflicts with in the estate, are presets/attach-points.yaml, compiled into satz. The compile refuses the estate's own authoritative form of a membership a project attaches to:

attachment the estate must not
google_access_context_manager_service_perimeter_resource set the perimeter's status.resources; and its lifecycle { ignore_changes = [status[0].resources] } must be written, or the estate's apply removes what projects attach
google_<node>_iam_member declare google_<node>_iam_policy or google_<node>_iam_binding on the exported node
google_compute_shared_vpc_service_project, google_network_connectivity_spoke — the provider has no authoritative form

The same rules hold whichever form a project is written in:

A project written in HCL: satz check-consumer <dir> [<estate>], offline: it reads the .tf files under the project's directory and compiles the central estate in memory. A value reads the estate when it is module.<name>.<output> of a module whose source ends in <interface>/hcl, local.interface.<export> or local.interface_static.<export> as the interface.tf a project estate's transpile writes names it, or a literal equal to a value the estate publishes or an identity it writes; anything else is the project's own. Each finding names the project's file and line, and any finding exits 1. A project runs it in its CI against a checkout of the estate; an agent runs satz_check_consumer.

A project written in Satz: its own compile, against the interface files it uses — they carry the attach points and the central resources the rules need, so the central estate is not read. A value reads the central estate when it is "${{interface.<export>}}" or a literal equal to a value it publishes or an identity it writes. Each finding is an error of the kind interface-use at the resource's line, and satz transpile refuses the estate as it refuses any other error.

Contribute — the write goes into the central estate. Where no attachment resource exists, or the change needs coordination — a subnet whose range must not overlap another's, a new folder — the change is an entry in the central estate, applied by satz, and the estate then exports the result, the project's subnet or folder, for the project to read. A project hands its entries over as a pack the central estate uses, which fills a list param through a contributes_<param> param — vendored into the central estate by pull request, as the next section says.

What a project may ask for is declared. A list that takes requests carries a request point — the field that names an entry and the fields an entry may carry — and every interface's README.md lists them under What you may request. The project writes its entries as a small pack:

pack requests_payments version "1.0"

params {
  contributes_event_topics = [
    { name = "payments" retention = "86400s" },
  ]
}

checks it with satz check-request <file> <central estate> against a checkout of the central estate — the request points, then the estate's compile with the file in place — and its pipeline opens a pull request that copies the file into the central estate's repository (satz/requests/<project>.satz) and adds its use line. The review of that pull request is the change's approval, the central estate's git history its record, a refused request a closed pull request; the apply makes the change, and an each over the list turns the entry into a resource the interface publishes. The file is vendored, not used from the project's repository: a use of a path that points there would put the project's next edit on the central estate's next apply with no review.

The change notice

A project learns that an exported value changed through the delivery form the estate chooses. The map asks it as question oneof interface_notice, which is not required: none of its options is an answer, and every option is off by default. Each option is one form; Pub/Sub is the one satz has.

option pack what a project subscribes to
interface_notice_pubsub presets/interface-notice.satz the topic interface_topic names: one message per apply that changes an exported value; the object interface_object names holds the new values
params {
  interface_notice_pubsub = true
}

use "presets/interface-notice.satz" when interface_notice_pubsub

The project writes the subscription in its own state — a push to its CI's webhook or a pull from a runner (the pack).


Continuous verification

scripts/fleet-v1.sh checks, when run, whether every estate compiles on the current binary. Two Cloud Build triggers check each estate continuously, and also whether the live organisation matches it:

trigger when runs fails when
satz-check every push to main satz transpile --check <estate> the estate no longer compiles
satz-compliance nightly, Cloud Scheduler satz report-compliance <estate> --format markdown --out <file> --fail-on <statuses> a witness is DRIFTED or NOT ENFORCED

Both are one pack:

use "presets/ci/verification-runner.satz"
use "presets/ci/verification-runner-grant.satz"

The runner is a service account of its own and holds nothing about the estate. Inside the build, satz's first act is to exchange the runner's identity for the estate's IaC service account — exactly what it does on a workstation — and the grant pack is the one IAM binding that permits it. satz whoami inside the build reports the same identities it reports on a workstation.

Where the runner lives:

The build steps are inline in the trigger — there is no cloudbuild.yaml in the estate repository. Whoever controls the build file controls what runs as the runner; in the hosted shape that file would sit in a repository the MSP does not own. Here the pipeline is defined by whoever applies the pack. The steps install satz from the release at build time (ci_satz_release, default latest) and, for the nightly run, tofu, because update-schema needs it.

(An estate written by satz init already enables cloudasset.googleapis.com in its infrastructure project — the CIS pack claims CIS 5.0 §2.14 against exactly that resource, so a require cis-gcp-5.0 reporting it as a broken claim is the same fact reaching you earlier than the nightly run would.)

Before the first nightly run, enable Cloud Asset Inventory on the estate's infra project and grant an asset-viewer role at organization level: report-compliance needs both. unverified is not in the default --fail-on set, so a run without them fails only on drift. The pack writes nothing back; the result is the exit code and the build log. Committing evidence to the watched repository needs write access the runner does not have by default.

Scanning with Prowler

Prowler is a second opinion: it reads the live organisation and reports what it finds, where satz reports what the estate declares and verifies its own witnesses. Putting the two together is what makes a FAIL on a verified witness a CONTESTED row rather than a number in a different tool.

satz does not run Prowler. The scan spends API quota in every project of the estate, and Prowler reads as whoever is logged in rather than as the estate's IaC service account, so starting it is the operator's decision. What satz answers is how to point it at this estate:

satz prowler C0example.satz

Stdout is the command line and nothing else — paste it, or pipe it: satz prowler C0example.satz | pbcopy. What the line cannot say goes to stderr: the command to run afterwards, a scan that is not narrowed to projects because no project id resolved, each project left out of --project-ids by its address because its id is built from a reference to another resource ("acme-${{google_folder.x.folder_id}}"), which only an apply resolves, and a framework this estate names that Prowler has no equivalent of. Every argument comes from what the estate declares — the organisation id, the project ids (a {param} in an id is its value, as everywhere in the compile), and the frameworks it names.

--compliance is the UNION of two sources: the frameworks the estate is HELD TO (compliance_frameworks, the language reference) and the frameworks its packs CLAIM. They are different facts — an estate can claim CIS controls while its customer is audited against ISO 27001 — and the export an auditor reads has to cover both. An estate that binds no compliance_frameworks is scanned for what its packs claim, and stderr says that is all the line had. A framework satz ships a catalog for but Prowler has no equivalent of is NAMED as unmapped rather than mapped to something that looks close: a wrong --compliance argument scans the wrong control set.

--format json is the same answer for an agent, and satz_prowler serves it over MCP.

Where a scan's output goes:

evidence/prowler/<UTC date>/<scope>-<UTC date>T<HH>-<MM>Z.ocsf.json

evidence/ sits beside the estate and is git-ignored — a scan's output is full of a customer's project ids and findings. The date is UTC so two people in two time zones scanning on the same day write into one directory rather than two that look like two scans, and <scope> is org or projects after how the scan was narrowed. The file name carries the UTC minute satz prowler ran in, the same shape as the history entries report-compliance writes, with dashes for colons so the name is valid on every platform. The path is known before the scan runs because --output-directory and --output-filename are both passed: without them Prowler names the file after the moment it ran, and nothing downstream can predict it.

Run satz prowler again before each scan — a rescan after an apply included. Prowler appends to an output file that already exists: a second scan run from the first one's command lands after the first array's closing ], and the file holds two runs' findings and no longer parses. report-compliance, triage and remediation-plan refuse such a file, naming the line, column and byte offset where the second run begins.

Then fold the export back in — any of the three read the same file:

satz report-compliance C0example.satz --prowler evidence/prowler/2026-09-13/org-2026-09-13T08-30Z.ocsf.json \
  --format markdown --out evidence/held-to.md
satz report-compliance cis-gcp-4.0 C0example.satz --prowler evidence/prowler/2026-09-13/org-2026-09-13T08-30Z.ocsf.json \
  --format markdown --out evidence/cis-4.0.md
satz triage cis-gcp-4.0 C0example.satz --prowler evidence/prowler/2026-09-13/org-2026-09-13T08-30Z.ocsf.json \
  --format markdown --out evidence/triage.md
satz remediation-plan cis-gcp-4.0 C0example.satz --prowler evidence/prowler/2026-09-13/org-2026-09-13T08-30Z.ocsf.json

satz reads the OCSF export of Prowler 5 only, and checks the version the export carries: an older one is refused by its version rather than read as empty.

The security review and the documentation package

Two skills in skills/ do the two things an agent does beside satz and satz's binary does not: judge and author (ADR 0003). skills/security-review/ runs a CIS GCP Foundations v5.0 security review of one organisation and writes its two deliverables; skills/estate-documentation/ writes a customer's documentation package from the estate. Each is a folder (SKILL.md, scripts/, references/, assets/) and a zip of the same folder, skills/<name>.skill.

Installing a skill

The customer folder the skills read

Both skills start from a customer's shortcode and read the folder ~/projects/ccc/<shortcode>/ (CCC_ROOT names another root): the central estate repository <shortcode>-C<dirid>/ with its config.toml (yaml_dir names the estate directory, satz/ when absent), the estate file inside it, hcl/ and interfaces/; beside it the workload folders — a satz project estate (a config.toml and a .satz that uses an interface.satz) or an HCL project (.tf files that read module.satz.<export>); and audit/, which the review owns. Everything else there is left alone. The central estate's own view of its projects is satz interfaces <estate>.satz --format json --out -, which is where the skills read the projects' ids and request points.

Running the review

Phase 0 discovers the scope and checks the tools and the identity, with no cloud call but whoami:

python3 $SKILL/scripts/discover_scope.py acme --scan-date 2026-09-27 --write ~/projects/ccc/acme/audit/tools/scope.yaml
cd ~/projects/ccc/acme/acme-C0example && satz --config . whoami C0example.satz

Phase 1 creates the evidence live and read-only, as the estate's IaC service account: Prowler one service at a time with a deadline (scripts/prowler_scan.sh, the own project ids from phase 0 including the workloads'), then scripts/satz_evidence.sh — satz report-compliance with Checkov, triage and require for the central estate, and per workload the interface check (satz check-consumer on its HCL, satz transpile --check on a satz project). Nothing in this phase changes the organisation. Phase 2 builds the two documents offline from scope.yaml and the evidence (scripts/build_audit.py, then scripts/verify_outputs.py): the checklist of every control with its status, and the remediation plan of satz packs and gcloud steps, with a section per workload. The pack, its use line, the gcloud commands that meet and check a control and the risk of leaving it open come from the measures of each control in satz's report and require output — the packs' own claims (ADR 0073); assets/measures.yaml holds what the skill authors: the German prose, the phases, the grouping of controls into plan sections, and the gcloud steps of the controls no pack claims. The documents are reproducible from scope and evidence and carry no local path; a change of wording goes into scope.yaml or assets/measures.yaml, a changed command for a pack's control into that pack's claim, never into the document. The skill's SKILL.md carries the whole procedure and its rules.

Building the documentation package

uv run --with python-docx --with matplotlib python3 $SKILL/scripts/build_estate_documentation.py --shortcode acme --index 01

The script finds the estate repository and its estate file, runs satz questions … --format xlsx and --format pdf for the decisions, reads the facts from the estate and its hcl/main.tf, draws the diagram of the organisation, its folders, projects and workloads, and writes the short description with the facts, the group model, the enabled services, the organisation policies, the compliance framework and the workloads — their Google projects, what each reads through its interface and what the estate lets a project request. The review's two documents are copied in from audit/; the package refuses to be written while one of the four files is missing. Nothing in the package is written by hand: a change goes into the estate and the package is rebuilt.

Keeping presets current

How to tell whether a newer preset exists, what to do about it, and which command to reach for.

the library presets/ on the satz repository's main branch; a push publishes a pack satz get-presets installs what is missing, refreshes what the estate does not use; in-use: refused the estate's presets/ X.satz upstream's pristine copy X.local.satz your fork, never touched X.diff.satz fork vs pristine, rewritten on every merge the suffix says who owns the file satz check-presets x.satz read-only; per pack, the version line and the content comparison: clean · STALE · fork EDITED (variables only | structural) missing locally · local-only drift in an [included] pack exits non-zero satz merge-presets behind upstream, unedited → refreshed in place edited, and upstream changed its meaning → forked to X.local.satz, the estate repointed, the delta in X.diff.satz — proven by an identical transpile comment or format churn only → upgraded silently a pack the library gained → its use line written, commented, under its phase --adopt takes upstream in place; --report-only writes nothing the estate file use "presets/estate-core.satz" // after the switch to the service account // use "presets/cis/CIS-GCP-Foundation-4.0.satz" params { … } a pack is on when its use line is uncommented; answering its question true uncomments it, and nothing re-comments it satz questions x.satz --unanswered what a new pack, or a new version of one, asks; bootstrap and apply refuse while any question is open satz interview x.satz one question at a time; each answer is bound as a param; a pack question answered true uncomments its use line --accept-defaults binds every default satz transpile → plan → apply one pack per apply, as the service account the compile reports a question answered true whose use line is still commented, or missing: an answer nothing emits After every satz release: back to check-presets. A pack the estate includes never changes silently — a semantic change forks, comment churn upgrades. SATZ STUDIO the same loop through the app's views — satz's own writer binds answers and toggles the lines Map one row per pack: Enable, its switches, a segmented oneof; a pack with no line reads Absent → merge-presets, one click Interview the questions a pack brings, with why, reversal and blast; accept one default or all; the rail shows how many are still open Commands check-presets, get-presets, merge-presets, questions, transpile, plan — run in the app with the log streamed; apply in the terminal Diagnostics · Chat the compile's findings at their file and line, incl. a true answer with no line; Chat drives the same satz tools, writes only on approval
The pack loop: the library, the estate's presets folder with pristine, fork and diff files, check-presets and its states, merge-presets and what it writes into the estate file, then questions, interview and one apply per pack; below, the same loop through satz studio's Map, Interview, Commands and Diagnostics views

Files and owners

There is one presets/ folder per estate, and the filename suffix declares who owns the file:

file owner what may happen to it
X.satz upstream overwritable — this is a pristine copy
X.local.satz you never touched by any command
X.diff.satz the tool the current fork-vs-pristine delta, rewritten each merge-presets run
<own>.satz you no upstream counterpart, kept as-is

Two more facts:

The three commands

command reads writes estate-aware protects you
get-presets upstream missing + unused files yes yes (refuses in-use; --force overrides)
check-presets <estate> both nothing yes n/a (read-only)
merge-presets both pristine names + forks + diffs yes yes

get-presets populates the library: it installs what is missing and refreshes what the estate does not use. A pristine pack the estate does use is refused, naming the two commands that fit instead, because changing it changes what the organisation enforces. --force overrides, listing each in-use pack as it overwrites it.

check-presets <estate> is the read-only report. It walks the estate's use graph, so packs the estate actually includes are tagged [included], and drift in an included pack exits non-zero — that is the CI gate.

merge-presets updates the library, and a preset the estate includes never changes silently. When upstream has moved semantically, it preserves your current content as X.local.satz, repoints the estate's use at that fork, proves the repoint by transpile identity, refreshes the pristine X.satz, and writes X.diff.satz — the exact delta adopting upstream would make. Comment and formatting churn upgrades silently instead of forking. It also writes the commented use line for every pack the pristine source's pack-graph.json offers and the estate has no line for, each where the graph's order puts it — after the pack before it, inside the block the graph names — so --pristine-dir <dir> writes the lines of THAT directory's graph. A pristine source without a graph gets a note and no line; a graph that places a pack in a block this binary's scaffold does not have is refused before anything changes.

When a pack line has no gate

Every pack but estate-core and the map is switched by a gate, and a no switches it off only through a line written use "…" when <gate>. An active line of such a pack written without when deploys the pack whatever the answer says. The compile reports each one as an ungated-pack finding with its file and line, satz packs lists the line as ungated, and satz remove-pack refuses to switch the pack off through it.

The finding names both halves of the edit: write use "<path>" when <gate> on that line, and bind <gate> = true in the estate's own params { }. The line deploys the pack, and the binding keeps it deploying once the gate decides; satz remove-pack is what switches it off afterwards. No command writes the when.

When a release moves a pack

A use of a path the library moved is refused, naming the file and the line, the path the pack lives at now and the edit. The old file is still in the estate's presets/, so following it would compile a copy nothing updates again, at the version it had when the library moved it. No command rewrites the line or moves the files beside it: the steps are in Breaking changes.

Is it stale, or edited?

Find out first, without touching anything:

satz --config <estate-dir> check-presets satz/<ESTATE>.satz

The GitHub API allows 60 unauthenticated requests an hour, and each run spends one. Without network access to GitHub, or with the quota spent, compare against a local checkout:

satz --config <estate-dir> check-presets --pristine-dir ~/projects/satz/presets satz/<ESTATE>.satz

check-presets reports two independent things: the version line says whether a newer release exists; the content comparison says whether anyone edited this copy.

Drift in an [included] preset exits non-zero — that is the CI gate.

To choose between adopting and merging, compare the local file with the release it claims to be:

# what release does the local file claim to be?
grep -m1 '^pack' <estate>/presets/cis/CIS-GCP-Foundation-4.0.satz     # -> version "1.5"

# is it byte-identical to that release?
cd ~/projects/satz
git log --format=%H -- presets/cis/CIS-GCP-Foundation-4.0.satz \
  | while read c; do
      v=$(git show $c:presets/cis/CIS-GCP-Foundation-4.0.satz | grep -m1 '^pack')
      echo "$c $v"
    done | head           # find the commit that carried v1.5
git show <that-commit>:presets/cis/CIS-GCP-Foundation-4.0.satz > /tmp/pristine-1.5.satz
diff /tmp/pristine-1.5.satz <estate>/presets/cis/CIS-GCP-Foundation-4.0.satz
result meaning what to run
no diff STALE — unchanged since that release adopt: copy the pristine file in
diff EDITED — a real local change merge-presets — let it fork and give you X.diff.satz

Without a baseline merge-presets cannot tell the two apart, so it forks. For an edited pack that is correct; for a stale one it moves the estate onto a fork it does not need. Adopt stale packs explicitly.

Adopt, merge, or fork

Your copy is stale — adopt

satz --config <estate-dir> merge-presets --adopt CIS-GCP-Foundation-4.0 --report-only
satz --config <estate-dir> merge-presets --adopt CIS-GCP-Foundation-4.0

It overwrites the pristine name in place, leaves the estate's use alone, and prints the emission delta — which resources appear or disappear, by address. --adopt all does every pack that is merely BEHIND, and refuses one that differs at the same version: that is an edit, and it has to be named. A fork+repoint needed in the same run is deferred to a separate run: the repoint is proven by transpile identity, and an adoption changes the output, so the two cannot share a run.

merge-presets does not regenerate hcl/. Continue with the normal gates:

satz --config <estate-dir> transpile satz/<ESTATE>.satz

cd <estate-dir>
git status --short          # only presets/ + hcl/ should move
git diff hcl/main.tf        # THIS is the real review — the emission delta
satz --config . require cis-gcp-4.0 satz/<ESTATE>.satz   # compare with the previous verdicts
satz --config . check-presets --pristine-dir ~/projects/satz/presets satz/<ESTATE>.satz

Then read the plan before applying:

cd hcl && tofu plan

Adoption is only a no-op when the moved default is one your estate overrides, or the pack is not used at all. Otherwise expect a real plan and gate it with a runbook.

Your copy is edited — merge

cd <estate-dir>
git status --short                      # must be clean: auto-repoints refuse a dirty estate
satz --config . merge-presets --report-only   # preview every planned action
satz --config . merge-presets

Afterwards you have X.local.satz (your content, now the thing the estate uses), a refreshed pristine X.satz, and X.diff.satz telling you exactly what adopting upstream would change. Read the diff; adopt when you are ready by pointing the estate's use back at the pristine name and deleting the fork.

Exit code is non-zero when anything needs attention — a fork was created, a fork's upstream moved, or a repoint was refused. That is the CI signal.

The estate runs a fork already

If the estate uses X.local.satz, copying pristine over X.satz changes nothing it emits. The change has to be made in the fork. Do not refresh the pristine sibling: it is the fork's baseline for the next merge, and overwriting it loses the record of where the fork branched.

Rules of thumb

When upstream stops answering: the GitHub quota

All three commands read the preset library from GitHub, and GitHub's unauthenticated REST quota is 60 requests per hour, per IP — shared with satz self-update. A sweep across a fleet can exhaust it, which also blocks self-update until the quota resets.

When the quota is exhausted, satz says:

GitHub API rate limit reached (60 requests/hour, unauthenticated). Retry in ~48
minutes, set GITHUB_TOKEN, or compare against a local checkout with
`--pristine-dir <checkout>/presets`.

Three ways around it:

One invocation costs one API request: the whole preset subtree arrives in a single tree response, and the files themselves come from a host that is not rate-limited.

A 403 that is not the quota — a private repo, a bad token — reports as a plain status instead, because waiting an hour would not fix it.