satz housekeeping

A handful of files are derived from something outside the repository — Google's provider schema, Google's constraint catalogue, Google's asset-type list, a CIS benchmark release. When the source changes, these files go stale without any test failing.

The first half of this page lists each of them: what refreshes it, what makes it stale, and what detects it. The second half describes each script: the refreshes and the gates. Every script in scripts/ has a section, and so does the one script a pack binds as an action (presets/scc/scc-enable-all.sh, which lives under presets/ because get-presets ships it).

At a glance

file refreshed by trigger what catches staleness
tests/schemas/google.json scripts/update_schema_fixture.py --add the provider pin moves nothing — run --check
presets/cai-asset-types.txt scripts/update_cai_asset_types.py, from Google's published list new asset types appear; Google retires one --check against the page, run by hand; indirect: unfilled import-config rows, and a live import naming the retired types it left out
presets/import-config.yaml (rows) scripts/update_import_config.py --config-file … --schema-dir … --provider-version … the provider pin moves cargo test: provider_version must equal the pin
presets/import-config.yaml (asset_type) scripts/update_import_config.py --config-file … --cai-types <list>, then --probe <parent> the CAI list above changes; Google changes what ListAssets serves smoke: "every derivable asset_type is filled"; a type ListAssets refuses: the live import leaves it out and names it with the rows that asked for it
presets/type-map.yaml — in an estate's presets_dir, not in this repository satz map-types, from the Discovery Documents and the provider schema the provider pin moves; Google changes an API nothing — an import without it still flattens nested values onto the attributes the provider schema names, and reports what it could not place; what the file adds is the renamed BLOCKS
presets/interface-lookups.yaml (the data source, keys, permission and attributes per type the interfaces read back) by hand, from the provider's data source schemas (tofu providers schema -json) the provider pin moves; an export needs a type the table has no row for the rows the showcase uses: smoke, tofu validate of every interfaces/<…>/hcl module; the other rows: nothing
presets/attach-points.yaml (the attachment types an export may allow, the argument that names the shared object, and the estate's authoritative form of each membership) by hand, from the provider's resource documentation (which attachment resources exist, and which attribute of the shared resource they fight with) the provider pin moves; a new attachment resource or a changed ignore_changes requirement in the provider's docs the rows' shape: cargo test (every_attach_row_is_an_attachment_with_a_target); whether a row matches the provider: nothing
presets/managed-constraint-equivalents.txt scripts/update_constraint_equivalents.py Google ships a new managed twin cargo test catches the effect, not the table
presets/docs/*.md satz doc-packs any pack changes smoke: doc-packs --check
skills/<name>.skill (the packaged form of skills/<name>/, for Cowork and the desktop app) scripts/build-skills.py any file of the skill's folder changes smoke: build-skills.py --check, byte for byte
presets/pack-graph.json satz pack-graph any pack or the map's offers entries change smoke: pack-graph --check; cargo test (the_shipped_pack_graph_is_current)
presets/cis/*-dry-run.satz scripts/build_dry_run_fragments.py the enforcing fragment it is derived from changes smoke: --check, which compares byte for byte
presets/CHANGELOG.md (## Changelog) by hand, one row per pack version a pack version changes doc-packs --check: fails on a version with no row
presets/catalogs/*.yaml by hand, from the benchmark a benchmark release nothing
src/prerequisites.rs (the prerequisite table: roles and APIs per type) by hand, from Google's predefined roles a pack emits a new resource type; Google changes a role a new type: cargo test (prerequisites_gate); a changed role: nothing — run scripts/check_prerequisites.py
tests/corpus/*/expected.sorted.txt UPDATE_CORPUS=1 cargo test emission changes cargo test (that is the gate)
provider version pin by hand a provider release nothing
crate versions cargo update routine cargo test after the fact
NOTICE (its third-party entries) by hand, one entry per piece of foreign material material from outside the project enters or leaves the tree nothing
THIRD-PARTY-LICENSES.md scripts/update-third-party-licenses.sh Cargo.lock moves — a dependency added, removed or updated the checks job of smoke.yml: --check, which compares byte for byte
the published releases .github/workflows/prune-releases.yml, after every release a release it keeps the five newest AND the release satz_core::LAST_YAML_CONVERTING_RELEASE names, read out of the source at that commit — a refusal that sends an operator to a release that was pruned is a dead end, and --cleanup-tag takes the tag with it
.github/workflows/release.yml dist generate, run with the cargo-dist version dist-workspace.toml pins dist-workspace.toml changes; the cargo-dist-version pin moves the release-workflow job of smoke.yml: dist generate --check, which prints the diff
assets/fonts/ — eight faces and the two licence texts by hand, copied out of the typst-assets crate in the dependency tree the typst pin moves a missing face: cargo test (each_family_carries_all_four_styles); a face behind the crate: nothing
src/privacy_shapes.rs (the gate's content rules, a second copy of scripts/check-names.sh's) by hand, in the same change as the script a rule of the gate changes cargo test (the_gate_script_and_satz_flag_the_same_tokens_in_every_corpus_file): both copies over tests/privacy-shapes/, token by token
docs/competitive.md a battle review quarterly, or a phase gate nothing
editors/zed/extension.toml (the pinned tree-sitter grammar) by hand: a commit in the grammar repository, then the pin the language changes (crates/satz-core/src/satz.rs) scripts/check-grammar.sh: the grammar job of smoke.yml on every push and PR, and the grammar repository's own weekly CI against a fresh clone of this one

Six have no automatic check, and the IaC role table has none for a changed role, nor the fonts for a face behind the crate, nor the interface lookups for a row the showcase does not use, nor the attach points for whether a row matches the provider: refresh them on their trigger.

The provider schema fixture

tests/schemas/google.json — 58 resource types, cut from the real provider.

The corpus and the smoke estate classify types through this fixture exactly the way production classifies them through a real schema. A type missing from it loses schema-derived detail without an error (an alert policy's notification_channels, for example), and a type whose real schema has changed makes the snapshots pin an older provider than the pin says.

uv run scripts/update_schema_fixture.py --check
uv run scripts/update_schema_fixture.py --add google_compute_firewall_policy_rule

--check downloads the pinned provider and reports, per fixture type, whether it is gone or whether its attribute and block surface has drifted. Run it when the provider pin moves; no other check reports it.

The script never deletes types, because a scan of the sources misjudges usage in both directions. Three fixture types appear in no .satz file because the emitter produces them from structural nodes — google_folder_iam_member from a grant map inside a folder, google_cloud_identity_group_membership from members, google_logging_project_sink from a sink. And google_compute_address is referenced, inside a raw hcl { } block, where no schema is needed. Trimming by apparent use would remove three types that are used.

The Cloud Asset Inventory type list

presets/cai-asset-types.txt — 601 asset types, Google's list, one per line.

satz import can only discover a resource type that carries a CAI asset type, so this list is the ceiling on discovery coverage. scripts/update_import_config.py --cai-types fills the asset_type column of presets/import-config.yaml from it (453 of the 1280 rows carry one) — the column is never filled by hand.

Refreshing it is scripts/update_cai_asset_types.py, then update_import_config.py --cai-types. The script reads the names from Google's asset-types page with the markup removed: the page breaks each name for wrapping with <wbr> tags, which is why matching the raw HTML finds only a fraction of them. A type the file carries that the page no longer names stops the run and is listed; delete its line by hand once checked. --check says whether the file is behind the page and writes nothing.

The 388 rows that stay TODO/UNKNOWN are Terraform types Google's list does not carry under the name the deriver builds — most are not Cloud Asset resources at all. Filling one takes a KIND_OVERRIDES entry in update_import_config.py, written by hand for a type worth discovering; a refreshed list does not fill them.

Staleness shows up indirectly: rows in import-config.yaml whose asset_type stays TODO/UNKNOWN when the type demonstrably has one. The smoke step "import-config: every derivable asset_type is filled" fails when the config is behind the list — it does not notice when the list itself is behind Google.

The other direction — a type the list carries and Cloud Asset Inventory has since retired — shows up in a live satz import: the sweep finds it by halving the request the API refused, leaves it out and names it at the end of the run with the rows that asked for it. That is the signal to refresh, and the type is out of the estate until it is acted on.

The managed/legacy constraint pairing

presets/managed-constraint-equivalents.txt — 15 pairs Google declares, 1 this repository adds.

Where Google replaces a legacy org-policy constraint with a managed one, the packs run the replacement alone and declare the twin off; see Superseded legacy constraints in the presets guide. The pairing is data, generated from a live organisation, so a test checks the rule.

uv run scripts/update_constraint_equivalents.py

Needs ADC and a quota project. Nothing about the organisation reaches the file — the constraint catalogue is Google's and identical for every customer. The section below the CURATED marker is preserved: it holds the one pair Google does not declare.

The rule is gated offline by constraint_equivalents::no_pack_runs_a_superseded_constraint, which fails when a pack enforces a superseded constraint or enables a replacement without declaring its twin off. A twin Google added after the last refresh is not in the table, so the gate cannot check it: refresh the table when adding a constraint to a pack.

The catalogs

presets/catalogs/ — cis-gcp-4.0.yaml, cis-gcp-5.0.yaml, iso27001-2022.yaml.

Control ids, titles and paraphrases, transcribed from the published benchmark. There is no machine-readable source to generate them from, so this is hand work with no check: a benchmark revision is absent until someone adds it, and reports keep using the older one without saying so.

Two rules for transcribing:

An unclaimed control reads unmet, not absent, so catalog ids can be added before the packs that implement them.

The pack surface

The IaC role table

src/prerequisites.rs — per resource type, the permission the IaC service account needs to manage it and the predefined roles that carry that permission, plus the reads every estate needs. satz update-prerequisites <estate> checks an estate against it and writes the missing roles into the estate, --report-only lists them and writes nothing; satz whoami <estate> tests the permissions live. satz update-prerequisites --format json prints it.

Two triggers make it stale:

The third-party notice

NOTICE names every piece of material in this repository that came from outside it: the provider schema fixture and the provider's own text inside it, the release workflow cargo-dist generates, and the control identifiers the catalogs carry. Apache 2.0 §4(d) makes that file travel with every redistribution of satz, so material that arrives from outside and is not named there goes unattributed everywhere satz is redistributed. The crates satz is compiled from are the one thing it does not enumerate: their texts are in THIRD-PARTY-LICENSES.md, which NOTICE points at.

Nothing checks it. Write the entry in the same change that brings the material in, and delete the entry in the change that takes it out.

The third-party licence texts

THIRD-PARTY-LICENSES.md carries the licence text of every crate compiled into the binary — one copy per crate, as the crate ships it, because a copyright line is part of the MIT and BSD texts. MIT and Apache 2.0 ask a binary redistribution to carry them, so the file travels in every release archive: include in dist-workspace.toml names it beside NOTICE.

It is generated from Cargo.lock, never edited: update-third-party-licenses.sh writes it and --check fails on a stale one. about.toml holds the allow-list of licences and the four release targets, about.hbs the layout. The privacy gate skips the file: it is upstream authors' e-mail addresses and domains, written by a tool.

The two files divide the subject: a crate's licence is here, and everything that is not a crate — the provider schema fixture, the fonts satz typesets with, the control identifiers in the catalogs — is in NOTICE.

The embedded fonts

assets/fonts/ holds the faces --format pdf typesets with, compiled into the binary by include_bytes! in src/pdf.rs: Libertinus Serif and DejaVu Sans Mono, each in regular, bold, italic and bold italic. They are the typst-assets crate's own files, copied out of the crate in the dependency tree, so the bytes are the ones that engine version renders with:

cp ~/.cargo/registry/src/*/typst-assets-<version>/files/fonts/{LibertinusSerif-{Regular,Bold,Italic,BoldItalic}.otf,DejaVuSansMono{,-Bold,-Oblique,-BoldOblique}.ttf} assets/fonts/

Eight faces are 2.3 MB; the crate's whole bundle is 9.7 MB, the difference being New Computer Modern text and math, which nothing here sets. Two families in four styles is the square the markup can ask for: a heading, a strong span and a table header are bold, an emphasised span is italic, a code span takes the weight and slant of whatever it sits in. Typst answers a request it has no face for with the nearest face it has and reports nothing, so cargo test (each_family_carries_all_four_styles) resolves all eight and fails when one collapses onto another.

The trigger to refresh is the typst version pin in Cargo.toml moving: nothing compares the committed faces with the crate's. The licence texts beside them — LICENSE-Libertinus-OFL.txt (SIL Open Font License 1.1) and LICENSE-DejaVu.txt (the Bitstream Vera licence) — come from the same crate's NOTICE and are what those licences ask a redistributor to carry with the files; include in dist-workspace.toml names both, so they travel in every release archive beside NOTICE and THIRD-PARTY-LICENSES.md. The privacy gate skips the whole directory: upstream bytes and upstream licence text, written by nobody here.

Versions

Recurring reviews

The scripts

Everything in scripts/ exists because it cannot be a satz command or a preset. Two kinds live here: cloud steps with no provider resource (nothing in Terraform can express them, so they stay gcloud), and build-time helpers that maintain the repo's own data files.

A cloud step cannot be written in Satz, so it is a script. An estate can declare and invoke it: an action names the step, binds it to a script, and builds its arguments from the estate's own params, so satz run-actions runs it with the organisation id the estate already has. Nothing runs at transpile time, and an action carries no claim: the compliance plane cannot see into the script.

One of these files lives under presets/. get-presets downloads presets/** and nothing else, so a pack that declares an action must carry its script inside presets/ or ship an action that cannot find what it runs. scc-enable-all.sh is therefore presets/scc/scc-enable-all.sh, beside the pack that binds it. The rule that follows: a script a customer runs belongs under presets/; a script only this repository runs belongs in scripts/. Everything else on this page is the second kind.

script kind what it does
presets/scc/scc-enable-all.sh cloud step enable every SCC service at the org, inherit below. Under presets/ so get-presets ships it and the SCC pack can bind it as an action
update_import_config.py helper keep presets/import-config.yaml current: new provider types, and asset_type filled from Google's Cloud Asset Inventory list
update_cai_asset_types.py helper refresh presets/cai-asset-types.txt from Google's published asset-type list; refuses when a type it carries is no longer published, --check says whether it is behind
build-skills.py helper + gate package every skills/<name>/ as skills/<name>.skill, stored and sorted so the bytes are the same on every platform; --check names a zip behind its folder
smoke.sh gate every estate-consuming command end to end against tests/smoke/; CI runs it on every PR and every push to main
mcp-probe.py gate every tool of satz mcp over raw JSON-RPC, per capability group: no stdout byte that is not JSON-RPC, results valid against their published schema, refusals as prose, each tool's largest result. Run by hand, not by CI
fleet-v1.sh gate every estate you operate, re-transpiled on the current binary and compared block by block against what it emitted before. Not run by CI — CI has no estates. Run it after every release
update_constraint_equivalents.py helper refresh presets/managed-constraint-equivalents.txt — which managed constraint replaces which legacy one — from a live organisation's ListConstraints
update_schema_fixture.py helper keep tests/schemas/google.json in step with the types the packs emit; --check names what is missing
inspect_schema.py helper print one resource type's schema out of a provider schema dump
build_dry_run_fragments.py helper generate each CIS extension's dry-run twin from the enforcing fragment: spec becomes dry_run_spec, every claim is dropped. --check fails on a stale twin
check_prerequisites.py gate hold the prerequisite table's roles (src/prerequisites.rs) against Google's predefined role definitions; needs ADC, not run by CI
build-satz-doc.py helper render one docs/*.md as a self-contained, theme-aware HTML page (SVGs inlined)
build-site.py build render the documentation site (README, the docs/*.md named in SITE_DOCS, the presets docs) into _site/ with a sticky navigation header, a per-page contents column (on a pack page followed by the library, grouped as presets/library-groups.txt sorts it) and a client-side search over every page's headings and text (search-index.js, no external dependencies; / focuses the box). Publishing is explicit: a doc must be listed in SITE_DOCS or SITE_DOCS_EXCLUDED or the build fails naming it. .github/workflows/pages.yml publishes on GitHub Pages on every release tag and on demand
check-names.sh gate refuse any identifier that is not one of the example customers (docs/examples.md); judged per TOKEN (an allowed address never shields a private one beside it); CI on every PR and every push to main (--commits A..B, an unusable range is a failure, never a pass), --staged from the pre-commit hook, --message FILE from the commit-msg hook, FILE… for one file (missing file = failure). Its allow-lists are scripts/check-names-allow.txt, which satz compiles in for review-pack
update-third-party-licenses.sh helper regenerate THIRD-PARTY-LICENSES.md, the licence text of every crate compiled into satz, from Cargo.lock with cargo about; --check fails on a stale file or a licence outside the allow-list. CI runs --check in the checks job
check-grammar.sh gate hold the tree-sitter grammar editors/zed/extension.toml pins against the parser: every keyword in STATEMENT_KEYWORDS must be a node the grammar declares, and every .satz under presets/ and tests/ must parse with no ERROR or MISSING node; CI runs it on every PR and every push to main (smoke.yml, job grammar)

check-names.sh — the privacy gate

This repository is public; the estates it serves are private. The gate refuses any identifier shaped like private data that is not one of the documented example values in docs/examples.md — directory ids, org/project/folder numbers, billing accounts, GUIDs and their dashless 32-hex form, project ids, e-mail addresses, domains that are neither IANA-reserved nor a known vendor host, repository URLs and checkout paths — in files and in commit messages. It also refuses the local files that must never be staged (CLAUDE.local.md, *.local.md, .claude/, attestations.yaml, evidence/) and any commit whose author or committer is not a GitHub noreply address (<id>+<user>@users.noreply.github.com, noreply@github.com).

scripts/check-names.sh                  # the whole tree (CI)
scripts/check-names.sh --staged         # staged files + the identity about to commit
scripts/check-names.sh --commits A..B   # identities and messages of a commit range
scripts/check-names.sh --message FILE   # one commit message
scripts/check-names.sh FILE...          # specific files

It knows no customer, no company and no person: the allowlists are the example values and the vendor defaults every customer shares, and everything else of that shape is refused. It judges tokens, not lines — an allowed address never shields a private one beside it — and an unusable commit range or a missing file is a failure, never a pass. It runs under bash 3.2 (/bin/bash on macOS) as well as bash 5.

The allow-lists — the example values, the vendor hosts, the vendor-default GUIDs — are one file, scripts/check-names-allow.txt: one entry per line, <list> <ERE>, the entries of a list joined into one alternation. The script refuses to run without it or with a list that has no entry.

The content rules exist twice. The script is the gate: it runs with no toolchain and on a tree that does not compile. src/privacy_shapes.rs is the same rules in satz, so satz review-pack refuses a pack that carries a private-looking token (kind private-shape) on any machine, with no checkout of this repository. Both read the one allow-list file, and a cargo test runs the script (FILE mode, LC_ALL=C) and satz over every file in tests/privacy-shapes/ and fails on any token that one flags and the other does not, on a clean- file that flags anything, on a hit- file that flags nothing and on a rule no file exercises. A change to a rule changes both copies and adds the fixture that shows it. The fixtures carry every private-looking value split by %%, which the test removes before either reads them, so the gate finds nothing in the committed files. For that test, a content rule prints one <file>:<line>: <token> row per token under its ✗ <rule> title.

What it cannot see is a NAME. A display name or a company in prose has no shape, and "Log Admins" and a real customer's project name are the same kind of string. That is what the local, never-committed denylist ($NAMES_DENYLIST) is for, and what review is for.

Two settings come from the environment, so that no personal value is written into this public script. $NAMES_DENYLIST is the path of the denylist file, one extended regex per line; without it the denylist rule does not run. $NAMES_IDENT is an extended regex of one more address that may author or commit, appended to the noreply forms; without it only those forms pass, and a commit under any other address is refused with what to do: set git config user.email to the GitHub noreply address, or NAMES_IDENT in the clone that needs another one. CI sets neither, so what it judges is the same everywhere.

Enable the hooks once per clone — they run the gate before a commit and again on the message:

git config core.hooksPath .githooks

.github/workflows/names-gate.yml runs it on every pull request, over the tree and the commits the pull request adds, and on every push to main, over the commits that push adds. main is never force-pushed: a push whose previous main does not resolve fails the gate.

smoke.sh — the command matrix

The unit tests cover the engines and pin the bugs that were fixed; scripts/smoke.sh covers the commands, end to end, one check of each command's core behaviour. It runs, offline, against the fixture estate in tests/smoke/ (the shipped CIS, contacts and monitoring packs, a group with a member, org grants, a project with services and a bucket): transpile (then tofu validate when tofu is on PATH — no state, no cloud), require, check-presets against the repository's own presets (must be clean), import in its state shape (with the skipped report and import blocks), hcl shape (translated, then tofu validate; a vendor-shaped configuration that asks for the organisation, in tests/smoke/tf-handwritten/; and --wrap-all), the refusal a .yaml estate gets from import and from transpile, adopt (a credentials error without ADC), scan against a stand-in checkov that prints a report, every MCP tool, then cargo test. The providers every tofu init needs are downloaded once, into TF_PLUGIN_CACHE_DIR (default ~/.cache/satz-smoke/tofu-plugins).

scripts/smoke.sh                 # builds target/release/satz first
SATZ=~/.cargo/bin/satz scripts/smoke.sh   # uses that binary, never rebuilt
SMOKE_SKIP_CARGO_TEST=1 scripts/smoke.sh  # leaves out the closing cargo test

.github/workflows/smoke.yml runs it with OpenTofu installed, once per commit: on every pull request, and on the push to main that merges it or bumps the release. A branch push without a pull request runs nothing, and a release tag runs nothing, because it tags a commit main already tested. A newer push to a pull request cancels the run it supersedes, in this workflow and in the privacy gate; a run on main is never cancelled or queued behind another. The Rust jobs restore the cargo registry and the dependencies' build from Swatinem/rust-cache, so a run compiles only satz's own crates. The smoke job sets SMOKE_SKIP_CARGO_TEST=1, because the checks job runs cargo test on the same commit, and restores the provider cache with actions/cache. A new command that reads an estate gets a step here in the same PR.

mcp-probe.py — every MCP tool through the raw pipe

satz mcp is driven by clients, not by hand, so the probe is the client. It starts the server, speaks MCP 2025-06-18 to it and reads its stdout pipe itself. It does not use an MCP SDK: an SDK drops a line that is not JSON, so a test built on one passes while a real client's stream is corrupt.

uv run scripts/mcp-probe.py                          # offline, every group
uv run scripts/mcp-probe.py --groups read            # only what read allows
uv run scripts/mcp-probe.py --tools satz_triage,satz_fmt
uv run scripts/mcp-probe.py --mode net               # + upstream presets, a real Checkov
uv run scripts/mcp-probe.py --mode live --live-dir <estate-checkout> --live-estate <main>.satz

--satz names the binary (default target/release/satz, which cargo build --release writes); --work the directory it stages into and logs to (default a new temporary one), which holds each server's stderr and report.json, every result as data.

What each call is checked for. Any stdout line that is not a JSON-RPC message fails the run, charged to the call in flight. A success carries structuredContent that validates against the tool's outputSchema and equals the JSON in its text block. A refusal is an isError result whose text is prose. A missing or mistyped argument is refused, and the probe reports the form — isError or invalid_params. Each call's duration and size are recorded.

Where MCP 2025-06-18 is not met. The protocol requires an object outputSchema and an object structuredContent. KNOWN_DEVIATIONS in the script lists the tools that do not meet it, and is empty; a deviation it lists is reported as KNOWN, one it does not list fails, and one it lists that no longer occurs fails too, so the list shrinks with the fix.

The servers. One satz mcp process per concern, each over a copy of presets/, tests/smoke/ and tests/schemas/ staged into one root, so presets_dir sits inside --root, and each staged root a git repository, because merge-presets edits an estate only where the edit can be undone:

server started with what it proves
main the selected groups the exact tools/list set and each tool's schemas; a call before satz_open refused; a missing and a mistyped argument; then each tool once at the level it needs — the interview created and answered, remediation items annotated, transpile and then Checkov over what it wrote; satz_adopt refused without credentials (offline)
gp read,write get-presets from a pristine library into an estate that has none, then merge-presets report and write
ceiling --allow read every write and exec tool refused naming the group; every path outside the root refused, an existing and a missing one alike
gated --self-gated satz_restrict narrows and never widens again
identity --allow read runs_as and satz_whoami follow each estate opened in turn; an estate with a mode the compile refuses, or cloud mode without its account, is refused by every call that would act as it
live --allow read --mode live only: whoami online answers runs_as, report-compliance reads the inventory, adopt resolves without execute

The modes. Each includes the one before.

mode needs adds
offline nothing: the ADC path names a file that does not exist, and Checkov is a stand-in on PATH that prints a fixed report every tool that works without credentials, and satz_adopt's refusal without them
net the network satz_check_presets against the upstream library (one GitHub API request), satz_scan_checkov with a real Checkov — checkov, else uvx checkov; skipped, by name, when neither is on PATH
live ADC that may impersonate the estate's IaC service account; --live-dir, --live-estate, --live-config (default config.toml) the live server over a copy of that estate. It writes nothing to the organisation. Run it on the test organisation, never on a customer's

The report. After the per-call lines come the stray stdout lines, the protocol deviations, how each malformed argument was refused, and a table of each tool's largest result in bytes and in tokens estimated at four bytes each — the number MAX_MCP_OUTPUT_TOKENS is set from (satz mcp). A result over --limit-tokens (25,000, Claude Code's default) is a failed check of its own, because a client cuts an oversized result mid-JSON. The exit status is non-zero on any failed check or stray line.

When to run it. CI does not: run offline before a pull request that changes a tool, its arguments or its report type, and live on the test organisation before a release that changes a live tool.

fleet-v1.sh — every estate on the current binary

The script answers: does every estate you operate still compile on the current binary, and does it emit the same infrastructure? The corpus tests the compiler against fixtures, the smoke matrix tests the commands against one estate, and tofu plan tests one estate against its own organisation with that estate's credentials; none of them re-transpiles the fleet.

scripts/fleet-v1.sh ~/estates/acme            # one estate
scripts/fleet-v1.sh --roster ~/fleet.tsv      # the fleet
scripts/fleet-v1.sh --roster ~/fleet.tsv -v   # ... with the diffs

The roster

Which estates exist, and where, is not in this repository; it is operator state. The script takes it as an argument (or $FLEET_ROSTER) in either of two shapes:

# code<TAB>path, comments allowed
E01     ~/estates/acme
E02     ~/estates/bolt

or a markdown table, so a fleet note you already keep works unchanged — the code is the first cell and the checkout is the first backticked absolute-or-~ path, which may carry a note after it:

| E01 | Customer A | `~/estates/acme` (also elsewhere) | … |

A row without both is skipped as prose. The parser is strict: a lenient one would read ordinary prose and unrelated tables as estates, report each as UNAVAILABLE, and hide the estates that really were not checked.

Bare paths on the command line need no roster at all.

Reading the result

outcome what it means what to do
clean the address set is identical and no block body moved nothing
delta same address set, some block body differs not a release blocker. Carry it into the estate's next pickup and commit the re-transpiled hcl/
BLOCKER the estate does not transpile, or its address set moved — a resource appeared or disappeared stop the release until the change is explained
UNAVAILABLE no checkout, no config.toml, no hcl/ to compare against — or no provider schema, which estate repos gitignore as a derived cache, so a fresh clone has none run it where the checkouts are, or satz update-schema in that one. --require-all turns this into a failure

Exit codes follow: 0 clean, 1 blocker (or --require-all with an unchecked estate), 2 delta only.

A hand-written .tf in hcl/ is listed, not compared. The comparison set is what satz emits; a file this emission did not produce is listed with ! and its blocks are left out of the count. An estate may keep one: a write-only secret cannot come from the estate, so the variable block declaring it lives in hcl/ beside the generated files. Compared, its blocks would read as deletions. The script cannot tell a hand-written file from one an older satz emitted, so it names the file; check a named file that is not hand-written.

A subdirectory in hcl/ is the same thing one level up. satz emits flat files into hcl/ and never below it, so a modules/ or landing-zones/ tree an estate keeps there is its own, and the main.tf inside is not the emitted main.tf that shares the name. The walk is flat: a subdirectory is listed with ! and nothing in it is compared. Walked, such a tree would read as deletions on an estate whose emission has not changed at all.

Why a moved address blocks. A body delta renders the same resources differently. A moved address means the next plan creates or destroys a resource.

When the delta is the state backend

A delta in the terraform block's backend is a changed state backend, and tofu refuses the next command until it is re-initialised, once per estate:

cd hcl/ && tofu init -reconfigure

Local-mode estates need nothing, and satz migrate --mode cloud re-initialises on its own.

What it does not do

build-site.py — which docs become pages

build-site.py publishes the docs it names in one list and skips those in another, and fails naming any docs/*.md that is in neither:

The gate is a build error in both directions: a new doc cannot slip onto the site unreviewed, and cannot be silently left off it either. The smoke matrix runs the site build, so the check is enforced in CI.

The menu is a third list, gated the same way. NAV_ORDER names every page in reading order — satz, language, library, changelog, workflows, interview, mcp, examples, housekeeping, competitive, llms — and the build fails on a page it does not name, or on a name that is not a page, so the menu stays a table of contents rather than a directory listing. A page's title is its menu word after satz (# satz language, # satz library), with no trailing explanation; what the page is goes in its opening line. The browser tab shows that title as plain text, taken from the rendered heading, and a page without a # title fails the build.

The 48 per-pack pages under presets/docs/ are rendered and linked from the preset library, but carry no menu entry of their own — someone looking for a pack starts at the library. Each pack page's side column carries, beneath its own contents, the whole library: every pack page under its group, in the order of presets/library-groups.txt, the page itself marked. The library page's contents column shows its ## group headings as groups with the sections under them. A group folds: the one that holds the current page, or the section the reader is in, is open, and the others show their title alone. The build fails on a pack page in no group of that file and on an entry with no page.

An excluded doc stays in the repository and stays linkable — a link to one from a published page is rewritten to its GitHub blob URL rather than left as a .md href that 404s. So is a link to any other repository file the site does not publish, an ADR under docs/adr/ for one; a link to a file that does not exist fails the build, naming the page and the link.

Excluded, and why:

doc why not published
security-toolset-integration.md a proposal under rework; it describes an audit loop satz does not implement
fast-delta.md source material for the competitive matrix, which carries the conclusions
stage-b.md how the pipeline was built. The language reference is how it is used, and the migration commands are in the README

One page at a time

build-satz-doc.py is the renderer build-site.py imports. It turns one docs/*.md into a self-contained, theme-aware HTML page, inlining any SVG that sits beside the markdown and recolouring it through CSS tokens so it follows the viewer's theme. It parses with cmark-gfm, GitHub's own parser (ADR 0008), so a page shows what GitHub shows and every heading carries the anchor GitHub gives it. Both scripts declare that dependency themselves, so a bare uv run runs them. A link to an anchor its target page does not carry fails the site build.

Inline code never pushes a page or a column wide. In a table cell it breaks between its words and never inside one, so a long command cannot claim its column's whole width; in running text a word of up to 27 characters is kept whole as well, so --help never ends a line as --. A word too long to share a table row with two others, in a cell or in running text, may also break before a /, . or _, and a piece of it that is still that long where a lowercase letter meets a capital. In a heading every word may break before a /, . or _, because a pack's name at heading size does not fit a phone line. Whatever still cannot fit a line breaks where it must; a code block scrolls inside itself instead.

Run on its own, it renders the language reference:

uv run scripts/build-satz-doc.py [MD] [OUT.html] [TITLE]

Each page carries a contents column built from its own h2/h3 headings, sticky beside the text, marking the section in view. Below 1180px it becomes a collapsed block above the content. It is built from the rendered headings.

update_import_config.py — keep the type table current

Three passes over presets/import-config.yaml, all from data, never by hand; comments and row order survive (ruamel.yaml):

uv run --with ruamel.yaml scripts/update_import_config.py \
  --config-file presets/import-config.yaml \
  --schema-dir <dir-of-schema-json> --provider-version <pinned version> \
  --cai-types presets/cai-asset-types.txt
uv run --with ruamel.yaml scripts/update_import_config.py \
  --config-file presets/import-config.yaml --probe organizations/<n>

To refresh the list itself, run uv run scripts/update_cai_asset_types.py first (the list).

At the live shape an enabled row with asset_type: TODO/UNKNOWN (or an unknown content_type) is a hard error; an enabled row with no asset_type (a type Cloud Asset does not carry — Cloud Identity groups, say) is reported once as "state shape only" and skipped there.

build-skills.py — the packaged skills

python3 scripts/build-skills.py writes skills/<name>.skill for every skills/<name>/SKILL.md — the zip a Claude skill is uploaded as (Cowork, the desktop app), with the folder's files under <name>/. The folder is the source and the zip is derived: entries are stored, not compressed (so no zlib version shows), sorted by path, dated 1980-01-01, mode 0644 (0755 under scripts/), and __pycache__, .pyc and .DS_Store are left out, so one folder gives one byte sequence on every platform. --check exits 1 naming each zip behind its folder; the smoke matrix runs it, so a skill change that forgets the zip fails CI. A clone that wants the skills in Claude Code links the folder once, ln -sfn ../skills .claude/skills.

update_cai_asset_types.py — the asset-type list against Google's page

uv run scripts/update_cai_asset_types.py rewrites presets/cai-asset-types.txt from Google's published list of Cloud Asset Inventory types, reading the names out of the page's text with every tag removed (the page breaks a name with <wbr>). A type the file carries that the page no longer names is not dropped: the run refuses and lists it, because a type Google stopped publishing is a decision for whoever runs the script — delete the line by hand and run again. --check exits 1 when the file is behind the page. Then uv run --with ruamel.yaml scripts/update_import_config.py --config-file presets/import-config.yaml --cai-types presets/cai-asset-types.txt fills the import config from the new list.

update_constraint_equivalents.py — which managed constraint replaces which

Google publishes, per org-policy constraint, the constraint that replaces it (equivalentConstraint). The packs act on that pairing — run the managed replacement alone, declare the legacy twin off — so it lives in the repository as generated data:

uv run scripts/update_constraint_equivalents.py            # auto-detect org + quota project
uv run scripts/update_constraint_equivalents.py --org 123456789012

Writes presets/managed-constraint-equivalents.txt. Needs ADC and a quota project — the OrgPolicy API refuses bare ADC. Nothing about the organisation reaches the file: the constraint catalogue is Google's and identical for every customer.

The script:

The table is enforced offline by constraint_equivalents::no_pack_runs_a_superseded_constraint (src/main.rs), which compiles every corpus case and fails when a pack enforces a legacy constraint that has a replacement, or enables a replacement without declaring its twin off. Refreshing the table needs credentials; the rule is checked on every cargo test without any.

update_schema_fixture.py — keep the test schema current

tests/schemas/google.json is a real provider schema trimmed to the types the fixtures use, and the corpus classifies types through it as production does through the full schema. A type missing from it loses schema-derived detail without an error; a type whose real schema has changed makes the snapshots pin an older provider than the pin says.

uv run scripts/update_schema_fixture.py --check
uv run scripts/update_schema_fixture.py --add google_compute_firewall_policy_rule

--check downloads the pinned provider (from provider_version in tests/smoke/config.toml) and reports each fixture type as GONE or DRIFTED, listing the attributes and blocks that appeared or vanished. Run it when the provider pin moves; no other check does. --add inserts or re-cuts types from the real schema and leaves the rest byte for byte; regenerate the corpus afterwards.

It never deletes: three fixture types appear in no .satz source because the emitter produces them from structural nodes, and one referenced type needs no schema because it sits in a raw hcl { } block, so usage in the sources does not show which types are needed.

Needs tofu on PATH; talks to no organisation.

build_dry_run_fragments.py — the dry-run twins

An org policy can carry dry_run_spec instead of spec. Google evaluates every rule, writes a violation to the audit log for each action it WOULD have blocked, and blocks nothing — so a breaking control is sized against a live organisation before it is enforced.

The twin must measure exactly the policy that is later enforced, or its numbers answer a question nobody asked. So it is derived, not written:

uv run scripts/build_dry_run_fragments.py            # write the twins
uv run scripts/build_dry_run_fragments.py --check    # fail if any is stale

DRY_RUNNABLE at the top of the script is the list, with the param that gates each twin. A fragment reaches it when its constraint supports a dry run; five extensions do not, each for a stated reason in that table — the legacy constraints (Shielded VM, both CMEK list constraints) have no dry-run form, Access Approval is not an org policy, and the two on-by-default extensions have nothing to size.

Three things the derivation does:

Read the violations in Cloud Logging:

protoPayload.metadata."@type"="type.googleapis.com/google.cloud.audit.OrgPolicyViolationInfo"

Promote by switching the dry-run param off and the enforcing param on. Both on at once is refused, naming both params and what each choice means.

check_prerequisites.py — the prerequisite table's roles against Google's roles

The IaC role table names, per resource type, a permission and the predefined roles that carry it. The script reads the table from satz update-prerequisites --format json (of this checkout through cargo run, or of an installed binary with --satz), fetches each role it names from the IAM API (roles.get), and fails when none of an entry's roles carries the entry's permission: a typo in the table, a permission Google renamed, or a role Google narrowed.

uv run scripts/check_prerequisites.py                 # the table of this checkout
uv run scripts/check_prerequisites.py --satz satz     # the table of an installed binary

Needs Application Default Credentials; predefined roles are Google's and the same for every organisation, so any credential that may call the IAM API will do. Workspace entries are not IAM roles and are skipped. Run it when adding a row and on each provider pin move; CI has no credentials to run it.

check-grammar.sh — the editor grammar against every Satz file

The Zed extension in editors/zed/ highlights Satz through a tree-sitter grammar that mirrors the parser by hand (crates/satz-core/src/satz.rs). The grammar lives in its own repository, satz-tree-sitter, and editors/zed/extension.toml pins one commit of it. A statement the parser gains and the grammar has not followed reads as an error in the editor — or, where the statement is shaped like a resource block, as a resource block: nothing reports an error, and the editor reads the statement differently from satz.

scripts/check-grammar.sh clones the pinned commit into a temporary directory and runs two checks against it.

The two statement sets. STATEMENT_KEYWORDS in crates/satz-core/src/satz.rs is every keyword that opens a top-level statement — action, claim, estate, hcl, notice, offers, pack, params, question, suppress, use. The script reads that list and the node and token types the grammar declares in its src/node-types.json, and fails naming any keyword the grammar models nothing for. The list is derived rather than remembered: the unit test statement_keywords_are_the_parser_s_own_dispatch reads the dispatch in parse out of the source between its two markers and fails when the two differ, so a statement the parser gains reaches this gate.

The files. Every *.satz under presets/ and tests/ is parsed with the grammar, failing on any ERROR or MISSING node; *.diff.satz files are unified diffs and are skipped. GRAMMAR=<path> uses a local checkout of the grammar instead of cloning. It needs the tree-sitter CLI (brew install tree-sitter-cli) or node, through which it fetches the CLI, and python3 for the statement comparison.

CI runs it on every PR and every push to main (.github/workflows/smoke.yml, job grammar), and the grammar repository's own CI parses a fresh clone of this one on every push and once a week. A language change is a grammar commit first, then the pin bump here, in the PR that changes the parser; the job fails on a PR that changes the parser without it.

update-third-party-licenses.sh — the crates' licence texts

cargo about reads Cargo.lock, finds each crate's licence file in its unpacked source, and renders about.hbs into THIRD-PARTY-LICENSES.md: a summary table, then every licence text with the crates it belongs to. The script is the only way that file is written. It runs cargo about offline, so the sources on disk are the whole input and the network cannot change what the file says; a dependency whose source is not unpacked yet needs cargo fetch --locked first.

scripts/update-third-party-licenses.sh           # rewrite it
scripts/update-third-party-licenses.sh --check   # fail when it is stale, print the diff

about.toml decides what the file contains:

The script requires one exact cargo-about version and names it when another is installed (cargo binstall cargo-about@<version>). A different version can read a crate's licence file differently, and the file would then differ between a machine and CI with nothing in the repository having changed. That WANT= line is where the version lives: moving it is one edit plus a regenerated file in the same commit.

CI runs --check in the checks job of .github/workflows/smoke.yml, last, where the cargo cache already is. The job installs the version the script names — read out of the script — and runs cargo fetch --locked first, because a Linux build unpacks what Linux compiles and the file also covers the crates only Windows and macOS pull in.

build-satz-doc.py — one page, self-contained

build-site.py renders the whole site; this renders ONE docs/*.md as a single theme-aware HTML file with its SVGs inlined, for sending a page to someone who will open it from disk rather than from the site:

uv run scripts/build-satz-doc.py docs/language.md out/language.html ["Page title"]

Both paths are optional: with none it renders docs/language.md beside itself. Nothing gates it — the page it writes is not published, and the site build does not read its output.

inspect_schema.py — look at one type

Prints one resource type's schema out of a terraform providers schema -json dump, and lists near-miss key names when the type is not found. Edit the target variable to change which type it reports.

python3 scripts/inspect_schema.py <schema.json>

presets/scc/scc-enable-all.sh — Security Command Center services

Turns every SCC service on at the organization and makes everything below the organization inherit it.

presets/scc/scc-enable-all.sh --organization 123456789012            # dry run
presets/scc/scc-enable-all.sh --organization 123456789012 --apply    # write

Or let the estate supply the organisation id. presets/scc/scc-service-enablement.satz is a pack whose only content is an action binding this script, since service enablement has no provider resource:

use "presets/scc/scc-service-enablement.satz"
satz run-actions estate.satz              # print the resolved command line, run nothing
satz run-actions estate.satz --check      # the dry run above
satz run-actions estate.satz --execute    # adds --apply

args carries the reading form of the command, execute_args the one flag that writes. The pack declares phase = "before-apply", because enablement is a prerequisite for the SCC resources a later pack will declare.

Defaults

Every service is enabled except the ones below. A detector for a workload that does not exist yet costs nothing, so Container Threat Detection is on before the first GKE cluster exists.

These are opt-in:

flag what it adds why it is not the default
--with-optional WEB_SECURITY_SCANNER it actively crawls the customer's web applications, which needs the customer's consent
--with-optional ARTIFACT_ANALYSIS billed per image scan
--with-multicloud the AWS/Azure connector services they fail until an AWS or Azure connector exists

Without the flag those services are left alone: not enabled at the org, and not swept to INHERITED below it. Sweeping them would make descendants take the org's value, which is unset, and switch off a scanner enabled on one project.

Why it is a script

google/google-beta carry no binding for securitycentermanagement.googleapis.com's SecurityCenterService. Turning Security Health Analytics, Event Threat Detection, Container Threat Detection, VM Threat Detection, Web Security Scanner or DSPM on or off therefore cannot be written as a resource in any language satz compiles, and neither can tier activation. Provider 7.14.1 ships 35 google_scc_* / google_securityposture_* types and none of them is service enablement or a tier — the google_scc_management_* ones are custom modules only.

Everything downstream of activation is codeable and belongs in a preset: custom modules (SHA + ETD), sources and source IAM, notification configs, BigQuery exports, mute configs, and google_securityposture_posture(_deployment). Use the v2 notification resources: the v1 API answers This API is no longer available. Please use API V2 on a live org, so google_scc_notification_config does not work; google_scc_v2_organization_notification_config is the one to write.

It calls the API, not gcloud scc manage

The SDK knows 13 of the 17 services the API exposes. ARTIFACT_GUARD, ARTIFACT_ANALYSIS, AGENT_ENGINE_VULN_ASSESSMENT and EXTERNAL_EXPOSURE have no gcloud name at all — the CLI answers "is not a valid service name" — while the API sets them. The script therefore calls the API, which also needs no translation between its names and the CLI's (SECURITY_HEALTH_ANALYTICS against security-health-analytics); discovery returns the API's form. gcloud supplies the credentials and walks the hierarchy.

The API needs a quota project — --quota-project, defaulting to the active gcloud project — and refuses the call without one. --apply is the difference between validateOnly and a write.

What a live organization answers

Against a real organization, 38 of 41 calls succeed; the three that fail are the organization's own state:

What it does

Two passes:

  1. org pass — each service → ENABLED at organizations/<id>.
  2. descendant sweep — each service → INHERITED at every folder and every project under the org.

The second pass makes the organization the one place the state is set: a descendant with its own ENABLED/DISABLED overrides the organization, so enabling at the org alone does not reach it. (INHERITED is only a legal state below the org, which is why the two passes set different values.)

With --reset-modules each service's individual modules are set to INHERITED as well, clearing per-module overrides on top of the per-service ones.

Flags

flag effect
--organization ID numeric org id (required); organizations/123 is accepted too
--apply actually write — without it every call carries validateOnly
--services "a b c" use this service list verbatim instead of discovering it
--with-optional also enable Web Security Scanner and Artifact Analysis
--with-multicloud include the AWS/Azure connector services
--quota-project ID project the API bills the call to; defaults to the active gcloud project
--org-only enable at the org, skip the sweep
--descendants-only only sweep folders/projects to INHERITED
--reset-modules also set every module to INHERITED
--targets-file FILE explicit folders/<id> / projects/<id> list instead of hierarchy discovery

Before running it

It is a dry run by default. Every call carries validateOnly until --apply is passed.

The service list is read from the org, not hardcoded. It comes from the API's own securityCenterServices listing, so a service Google adds later is picked up without touching the script. The built-in list of 14 is only a fallback for when that call cannot be made, and the AWS/Azure connectors in it are skipped unless --with-multicloud, because they fail on an org with no such connector.

Activate the SCC tier first. Tier activation has no API the script could call, and enabling a service on an org on the Standard tier fails at the API.

Failures it classifies

Three failures are classified in the output instead of being left as a raw API error:

Exit status is non-zero if any call failed, so it can gate a runbook step.

Requirements

gcloud and jq on PATH, and a shell — it is written for bash 3.2, the macOS default, so no mapfile and no unguarded empty-array expansion.

The shape of the rule

Anything derived gets, in order of preference:

  1. a script in scripts/, so the refresh is reproducible and reviewable;
  2. a gate that fails offline when the derived file and its consumers disagree;
  3. failing both, a line on this page saying what goes stale and when.

A derived file with none of the three can go stale while every test passes.