Lesson 17 / 27
Workspaces and Environments
Two setups for three environments: a single definition plus workspace versus separate directories, the count of environment-specific differences, the number of signals that say which environment you are in and applying to the wrong one, and how many environments change together when the definition changes, along with the divergence.
Published Updated
In this lesson
- Builds three environments in workspace and separate-directory form
- Counts environment-specific differences and duplicated fields
- Ties the number of signals that say which environment you are in to wrong applies
- Measures how many environments change together when the definition changes, and the divergence
Contents
The previous lesson split state into parts, and environment appeared in the part names. How the environment distinction itself is built was never examined. If the definition of the three environments is largely the same, there are two paths: write a single definition and pick the environment with a setting, or keep a separate directory for each environment. This lesson actually builds both and measures the difference between them with three numbers.
How similar the environments are to each other — environment parity — was measured earlier and is not repeated here. The question here is not similarity, it is where that similarity is written.
Three Instances of the Same Definition
SM19. The configuration language below is the small form defined for this lesson; the
environment(...) expression picks a value according to the selected environment. The script
writes both setups to disk: a single definition plus workspace, and three separate directories.
// setup.mjs — the three environments are built two ways: single definition + workspace, and separate directories. // The configuration language is the small form defined for this lesson; the environment(...) expression picks a value per environment. import { writeFileSync, mkdirSync, rmSync } from "node:fs"; const environments = ["test", "staging", "production"]; const DEFINITION = `resource subnet backbone { block = environment(test="10.0.0.0/16", staging="10.1.0.0/16", production="10.2.0.0/16") } resource machine collector { count = environment(test=1, staging=2, production=6) cores = environment(test=2, staging=2, production=8) memory_gb = environment(test=8, staging=8, production=32) image = "measurement-collector-4.2" } resource queue read { retention_hours = environment(test=6, staging=24, production=168) partitions = environment(test=1, staging=4, production=12) } resource data_store measurement { size_gb = environment(test=20, staging=100, production=2000) backup = environment(test=false, staging=false, production=true) }`; // Resolves the environment(...) expression for the given environment; a non-conditional line is left as is. const resolve = (text, env) => text.replace(/environment\(([^)]*)\)/g, (_, ic) => { const match = new RegExp(`${env}=([^,]+)`).exec(ic); return match[1].trim(); }); rmSync("workspace", { recursive: true, force: true }); rmSync("separate-dir", { recursive: true, force: true }); mkdirSync("workspace/state", { recursive: true }); writeFileSync("workspace/definition.txt", DEFINITION); writeFileSync("workspace/.selected", "test"); for (const e of environments) writeFileSync(`workspace/state/${e}.json`, JSON.stringify({ env: e, items: [] })); for (const e of environments) { mkdirSync(`separate-dir/${e}`, { recursive: true }); writeFileSync(`separate-dir/${e}/definition.txt`, resolve(DEFINITION, e)); writeFileSync(`separate-dir/${e}/state.json`, JSON.stringify({ env: e, items: [] })); } const lines = DEFINITION.split("\n"); const conditional = lines.filter((s) => s.includes("environment(")).length; const fields = lines.filter((s) => s.includes("=")).length; const separateLines = environments.reduce((t, e) => t + resolve(DEFINITION, e).split("\n").length, 0); const sharedFields = fields - conditional; console.log(`workspace: 1 definition file, ${lines.length} lines, ` + `${fields} fields (${conditional} environment-specific, ${sharedFields} shared), 3 state files`); console.log(`separate dir: 3 definition files, ${separateLines} lines, ` + `${fields * 3} fields (${conditional * 3} written separately, ${sharedFields * 3} duplicated), 3 state files`); console.log(`environment-specific difference: ${conditional} fields; identical across all three: ${sharedFields} fields`);
workspace: 1 definition file, 17 lines, 9 fields (8 environment-specific, 1 shared), 3 state files separate dir: 3 definition files, 51 lines, 27 fields (24 written separately, 3 duplicated), 3 state files environment-specific difference: 8 fields; identical across all three: 1 fields
Both setups describe the same infrastructure, keep the same three state files, and produce the same environment distinction. The difference is in the amount of text written, and in where the difference stands.
In the workspace form, the eight environment-specific differences sit in a single file, inside eight conditional expressions. In the separate-directory form, the same eight differences spread across twenty-four separate lines — each environment carries its own value in its own file — and the one field identical across all three environments is duplicated three times.
What this first pair of numbers says is this: collecting the difference in one place makes it visible. To learn where the three environments diverge, looking at one file is enough in the workspace form; in the separate-directory form, three files have to be compared. Against that, in the separate-directory form each environment’s definition can be read on its own: understanding what production is does not require resolving conditionals in your head.
On the state side, there is no difference at all between the two setups: both keep one state file per environment, three in total. The numbers from the previous two lessons therefore apply the same way to both — the lock is taken per environment, the three environments can apply without waiting on each other, and losing one environment’s state only makes that environment unmanageable. The workspace shares the definition; the one thing it does not share is state, and this distinction comes back at the end of the lesson.
Which Environment Am I In
The second measurement belongs to operations. Before pressing the button, how many signals can the person starting the apply read to know which environment they are in?
SM20. In the workspace form, the selected environment sits in a hidden file and is changed with a separate command; it leaves no trace on the command line. In the separate-directory form, the choice is the directory the command runs in, and it shows up in the path. SM21. The probability of skipping the selection step is tied to the number of signals: fifteen percent without a signal, three percent with one. SM22. A step that prints the environment name to the screen and asks for confirmation before the apply catches nine out of ten skipped selections. The generator is written inside the lesson, and the seed is visible.
// env-choice.mjs — the step of knowing which environment you are in, and a model of applying to the wrong one. import { readFileSync, writeFileSync } from "node:fs"; let x = 20260801 >>> 0; // seed is visible const rand = () => { x = (Math.imul(x, 1664525) + 1013904223) >>> 0; return x / 4294967296; }; const environments = ["test", "staging", "production"]; // How many signals can the person starting the apply read to know which environment they are in? console.log("which environment am I in: workspace -> 0 signals on screen, 1 extra command (selected file)"); console.log(" separate dir -> 1 signal in the path, 0 extra commands"); const run = (label, forgetProb, confirmCatch) => { writeFileSync("workspace/.selected", "test"); let wrong = 0, fellToProd = 0; for (let i = 0; i < 200; i++) { const target = environments[Math.floor(rand() * 3)]; const current = readFileSync("workspace/.selected", "utf8"); let picked = target; if (rand() < forgetProb) { // selection step skipped: previous environment stands picked = current; if (rand() < confirmCatch) picked = target; // confirmation step caught it } writeFileSync("workspace/.selected", picked); if (picked !== target) { wrong++; if (picked === "production") fellToProd++; } } console.log(`${label.padEnd(29)}: 200 applies, ${String(wrong).padStart(2)} wrong environment, ` + `${fellToProd} fell to production`); }; run("workspace", 0.15, 0); run("separate dir", 0.03, 0); run("workspace + mandatory confirm", 0.15, 0.9);
which environment am I in: workspace -> 0 signals on screen, 1 extra command (selected file)
separate dir -> 1 signal in the path, 0 extra commands
workspace : 200 applies, 23 wrong environment, 8 fell to production
separate dir : 200 applies, 4 wrong environment, 1 fell to production
workspace + mandatory confirm: 200 applies, 2 wrong environment, 1 fell to production
Out of two hundred applies, the workspace form went to the wrong environment twenty-three times, eight of which fell to production. In the separate-directory form, the count drops to four, and the fall to production drops to one. The source of the difference is one single thing: whether there is a signal on screen saying which environment this is.
The third row matters more. When a confirmation step that prints the environment name before the apply was added to the workspace form, wrong applies dropped from twenty-three to two — better than the separate-directory form. The result says that neither setup is inherently safe: what is safe is the environment being visible. Separate directories give this for free because the path is already on screen; in the workspace form, the same visibility has to be built separately.
The cost of applying to the wrong environment ties back to the measurement in this course’s first lesson. Each of the eight applies that fell to production writes test values into production’s state: machine count drops from six to one, the data store from two thousand gigabytes to twenty. The gap between record and reality here is produced not by drift but by the record itself — and the moment the apply finishes, both the record and reality are wrong.
How Many Environments Change When the Definition Changes
The third measurement is about time. When a field is added to the definition, how many environments are forced to change together? SM23. Eight of twelve changes concern all three environments, four are environment-specific; in the separate-directory form, each manual move is skipped with a one-in-four probability. SM24. The apply was only run in the test environment.
// change.mjs — how many environments are forced to change together when the definition changes, and the divergence. import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; let x = 991 >>> 0; // seed is visible const rand = () => { x = (Math.imul(x, 1664525) + 1013904223) >>> 0; return x / 4294967296; }; const environments = ["test", "staging", "production"]; // Twelve changes: eight concern all three environments, four are environment-specific. const changes = []; for (let i = 1; i <= 8; i++) changes.push({ no: i, scope: "all" }); for (let i = 9; i <= 12; i++) changes.push({ no: i, scope: environments[(i - 9) % 3] }); let newConditional = 0, moveNeeded = 0, moveSkipped = 0; for (const d of changes) { if (d.scope === "all") { // single definition: one line, all three environments appendFileSync("workspace/definition.txt", `\n new_field_${d.no} = "d${d.no}"`); for (const e of environments) { // separate dir: moved by hand into each directory moveNeeded++; if (rand() < 0.25) { moveSkipped++; continue; } appendFileSync(`separate-dir/${e}/definition.txt`, `\n new_field_${d.no} = "d${d.no}"`); } } else { // environment-specific: needs a conditional expression newConditional++; const inner = environments.map((e) => `${e}=${e === d.scope ? `"d${d.no}"` : '""'}`).join(", "); appendFileSync("workspace/definition.txt", `\n new_field_${d.no} = environment(${inner})`); appendFileSync(`separate-dir/${d.scope}/definition.txt`, `\n new_field_${d.no} = "d${d.no}"`); } } const conditional = (s) => s.split("\n").filter((l) => l.includes("environment(")).length; const fields = (file) => new Set(readFileSync(file, "utf8").split("\n") .filter((l) => l.includes("new_field_")).map((l) => /new_field_\d+/.exec(l)[0])); const expected = (e) => changes.filter((d) => d.scope === "all" || d.scope === e).length; const missing = environments.map((e) => expected(e) - fields(`separate-dir/${e}/definition.txt`).size); console.log(`workspace: 1 definition file, ${changes.length} changes processed`); console.log(` conditional expressions 8 -> ${conditional(readFileSync("workspace/definition.txt", "utf8"))} ` + `(each environment-specific change adds one conditional)`); console.log(` shared change: forced to change together 3/3 environments, diverged fields: 0`); console.log(`separate dir: 3 definition files, ${changes.length} changes processed`); console.log(` shared change required ${moveNeeded} moves, ${moveSkipped} moves skipped`); console.log(` shared change: forced to change together 1/3 environments, remaining 2 by hand`); console.log(` diverged fields: ${missing.reduce((a, b) => a + b, 0)} ` + `(${environments.map((e, i) => `${e} ${missing[i]}`).join(", ")})`); // The definition changed, the apply only ran in the test environment. const behind = (form) => environments.filter((e) => e !== "test") .map((e) => `${e} ${expected(e) - (form === "separate" ? missing[environments.indexOf(e)] : 0)}`).join(", "); console.log(`record vs. reality (only test applied), fields waiting to be applied:`); console.log(` workspace -> ${behind("workspace")} | separate dir -> ${behind("separate")}`);
workspace: 1 definition file, 12 changes processed conditional expressions 8 -> 12 (each environment-specific change adds one conditional) shared change: forced to change together 3/3 environments, diverged fields: 0 separate dir: 3 definition files, 12 changes processed shared change required 24 moves, 2 moves skipped shared change: forced to change together 1/3 environments, remaining 2 by hand diverged fields: 2 (test 0, staging 2, production 0) record vs. reality (only test applied), fields waiting to be applied: workspace -> staging 9, production 9 | separate dir -> staging 7, production 9
In the workspace form, a shared change is a single line and changes the definition of all three environments forced together: 3/3. Diverged fields are zero, because there is no second file to diverge. The cost is paid at the other end: four environment-specific changes gave birth to four new conditional expressions; the conditional count rose from eight to twelve, and the file became a little less readable with every environment difference.
In the separate-directory form, the same eight shared changes required twenty-four manual moves, and two were skipped. The result is a two-field divergence in the staging environment. No one reports this divergence; both definitions are valid on their own, and both applies run without error. The difference is visible only when the three files are compared.
The skip rate itself is a measurement too. A one-in-four probability produced a two-field divergence from twelve changes in one cycle; if the rate stays the same, the gap grows a little bigger every cycle, and because no apply reports it, it accumulates. That the divergence lands in the staging environment in this run is an accident of the model — which environment will diverge is not known in advance, only that one will. This also means the separate-directory form needs a step that checks the moves: without a check that compares the three definitions and reports differences that are not environment-specific, divergence only surfaces as a difference in behavior in production.
The last two numbers show the record’s own risk. The definition changed in both forms, the apply only ran in the test environment. In the workspace form, staging and production are each left nine fields behind; in the separate-directory form, staging is seven, production is nine. The record has changed for all three environments, reality for only one. A single definition does not prevent the difference from accumulating; it only ensures it is written in one place. The gap between environments is born not in the definition, but in apply lag.
The Trade-off
The three measurements point in three different directions, and there is no single winner. When environment-specific difference is small, the workspace form wins: one file, zero divergence, eight conditionals. As environment-specific difference grows, the conditional expression count swallows the definition, and separate directories stay readable. The risk of applying to the wrong environment is five times higher in the workspace form, but a confirmation step brings it down to two — meaning this difference is not the setup’s doing, it is the doing of the guard placed around the setup.
The one thing worth dwelling on is what stays the same in both forms: the state of the three environments is three separate files. The workspace shares only the single definition; it does not share state. So a change applied in one environment does not happen automatically in the others, and the record being the same for all three environments does not mean reality is the same.
Summary
- The same infrastructure was built two ways: a single definition file, seventeen lines and eight conditional expressions; three separate directories, fifty-one lines, twenty-four fields written separately and three duplicated.
- Environment-specific difference is eight fields, one field stays identical across all three environments; the difference is collected in one file in the workspace form, and spread across three files in the separate-directory form.
- Out of two hundred applies, the count of going to the wrong environment is twenty-three in the workspace form, four in the separate-directory form; a confirmation step printing the environment name brought the count down to two.
- A shared change in the workspace form changes all three environments together and produces zero divergence; in the separate-directory form, two of twenty-four manual moves were skipped and two fields diverged.
- Four environment-specific changes raised the conditional count from eight to twelve in the workspace form; in the separate-directory form, each stayed a single line in a single file.
- The definition changed for three environments, the apply ran in one: in the workspace form two environments were left nine fields behind, and in the separate-directory form staging seven and production nine. The gap between environments is born not in the definition but in apply lag.
Next Step
Both setups in this lesson tried to solve one repetition: the same infrastructure being told three times. The workspace turned the repetition into a conditional expression; the separate directory repeated it outright. Neither eliminated the repetition, they only moved where it lives. When ten teams, instead of three environments, want to set up the same queue, the same collector machine, the same archive, neither form is enough; what is needed is packaging the definition itself, and naming and versioning its inputs. The remaining lessons in this topic take up the infrastructure module: input surface, version pinning, and how a breaking change propagates to every dependent user.
To keep your progress and take notes, Log in
My notes
Log in to take notes.