Best Practices Booklet

Building Better Instruqt Tracks
Twenty-five principles for tracks that work in production

25 principles for tracks that work in production.

5
Parts
25
Principles
100%
From Production
1
Booklet
In this booklet
PRE What This Is
Part I — Architecture and infrastructure
Part II — Lifecycle scripts
Part III — Check scripts that teach
Part IV — Content and assignment design
Part V — Shipping and maintenance
END A Closing Note

This booklet is a short, opinionated set of rules for authors of Instruqt tracks. It is not a tutorial, a reference, or a survey of what is possible. It is a list of habits that distinguish tracks that ship cleanly from tracks that fight their author at every step.

Each principle is stated as a single sentence, followed by a paragraph or two explaining why it earns its place on this list. Where a code snippet helps, one is included. The principles are organized into five themes — architecture, lifecycle scripts, check scripts, content design, and shipping — but they are meant to be readable in any order. Read them straight through the first time. Come back to a single page when you have a specific problem in front of you.

These rules are descriptive, not invented. Every one of them comes from production tracks where the wrong choice produced an actual symptom: a silent push failure, a learner stuck on a check that gave no useful feedback, a setup script that worked once and broke on every subsequent run, a credential committed to a repo and discovered weeks later. The rules are short because the symptoms were long.

The default of "what I used last time" is how tracks end up overprovisioned, slow to start, or unable to host the very tool they were built to demonstrate. The platform offers three compute primitives — containers, virtual machines, and sandbox presets — and they differ by an order of magnitude in boot time, capability, and maintenance burden.

Containers boot in under thirty seconds and ship with the Instruqt agent helpers built in. VMs boot in over a minute and require you to install or inherit your own tooling. Custom GCP images boot fast but commit you to maintaining the image as the underlying product evolves. Reach for a VM only when you genuinely need a display server, a real kernel, nested virtualization, or a heavy set of preinstalled dependencies. Reach for a custom image only when those dependencies are too heavy to install at setup time. Reach for a sandbox preset, when one exists, before any of the above.

The mistake is reaching for a heavier primitive without a specific reason. In doubt: start with the lightest option that meets your requirements and escalate only when a concrete need appears.

The two most-used Instruqt container base images differ enough in size and contents that the wrong choice imposes a real cost. The lighter image — a minimal shell environment — boots fast but ships with almost nothing a cloud-account track needs. The heavier cloud-client image carries the AWS, Azure, and GCP CLIs preinstalled, plus common scripting tools. The trap is reaching for the lighter image, wiring up an aws_accounts block, and then watching the learner type aws s3 ls on a container that has no aws binary on the path.

The fix is one of two: switch to the cloud-client image and accept the slightly longer pull time, or install the CLI in the track-level setup script before any challenge runs.

# In track_scripts/setup-workstation, when staying on the lighter image
curl -sSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscli.zip
unzip -q /tmp/awscli.zip -d /tmp
/tmp/aws/install

Both choices are valid. The first is faster to write; the second keeps your boot footprint small if you only need one cloud's tooling. The wrong choice is the one where you discover the problem from a learner's terminal output during a live demo.

When a vendor whose product your track demonstrates has packaged that product as an Instruqt sandbox preset, use the preset. Skipping config.yml and track_scripts/ entirely removes a category of failure modes — boot timing, image pulls, agent installation, environment variable injection — and lets you focus on assignment content rather than environment provisioning.

The trade-off is that you cannot modify a preset. If you need a custom dataset, a third-party CLI, or an additional exposed port, you have to switch back to a config.yml-based design.

A useful threshold: if the preset is a 90% fit for your track, use it and design around the 10%. If it is a 60% fit, build the environment yourself. The middle ground tends to produce tracks that fight the platform — half-preset, half-custom, with an integration seam that turns into the source of every bug.

Tracks that orchestrate multiple Docker containers on a workstation VM — for example, those using docker compose up to bring up a stack of services — pay a hidden tax if the images are pulled lazily during a challenge. A learner's first command becomes a several-minute wait while images download, often without a clear progress indicator. Worse, if the pull fails (network, registry rate limits), the failure surfaces as a generic command error rather than a recognizable "image not pulled yet" condition.

Pre-pull every image you know the track will use during the track-level setup script, in parallel:

docker pull example.com/service-a:1.4 &
docker pull example.com/service-b:1.4 &
docker pull example.com/service-c:1.4 &
wait

By the time the learner reaches the challenge, the images are local and the only delay is container startup. If a pull is going to fail, you want it to fail visibly during setup — where you can see it and add retries — not during the demo.

A common track shape pairs a portal page (HTML, sometimes with JavaScript) with a backend API the portal calls. The naïve architecture serves the portal on one container's port and the API on another's, then exposes both as separate service tabs. This works in the terminal — curl against the API hostname succeeds — but breaks the moment the portal's JavaScript tries to call the API. The learner's browser is talking to Instruqt's proxy, not your Docker network, and an absolute URL pointing at an internal hostname will fail with no usable error.

Serve both surfaces from the same hostname and the same port, routing by URL prefix on the server side. A single uvicorn or Flask process can handle the portal at / and the API at /api/*. From the browser's perspective, every fetch('/api/...') call routes back through the same proxy endpoint that served the page — relative paths just work, and the entire class of cross-origin headaches disappears.

The architectural simplicity is worth the small amount of extra wiring on the server side. If you remember nothing else about service tabs, remember this: one proxy endpoint, relative paths only.

config.yml is committed to git like any other source file. Anything you put in a resource's environment: block lives in repo history forever — including after you commit a "fix" that removes it. And during a play, the contents of environment: are visible to every learner who opens the Instruqt debug log. The combination is fatal for credentials: SSH keys, API tokens, license keys, and registry credentials all leak by default the moment they land in environment:.

The fix is the secrets: block. Declare the secret by name in config.yml; set the value in the Instruqt platform UI. The secret is injected into the host's environment at runtime, never appears in the source tree, and never shows up in the debug log.

secrets:
  - name: GITHUB_TOKEN
  - name: VAULT_LICENSE
  - name: VENDOR_API_KEY

The same rule applies to lifecycle scripts. A git clone https://${TOKEN}@github.com/... line with a literal token committed to the script is no different from a credential in config.yml — the script lives in the repo, the credential lives in history. Reference secrets: values from scripts the same way you reference any other environment variable: ${GITHUB_TOKEN}. If the secret is missing from the script's environment, the value will be empty and the operation will fail visibly — which is what you want, not silent success on a leaked credential.

A "fix it later" PR comment is too late. Once a credential lands in a commit, treat it as compromised: rotate the secret, then deal with the source tree.

Most services take longer to be ready than they take to be running. A postgres container accepts TCP connections seconds before the database accepts queries. A Flask app binds its port milliseconds before it can serve a request. A KasmVNC container responds to HTTP before its internal display server is fully up. Setup scripts that exit as soon as a container reports "running" race the actual readiness state, and the failure mode is a 502 in the learner's first interaction.

Replace process-up checks with readiness checks that exercise the actual interface the learner will use. For postgres:

until pg_isready -h db -U app -d app; do sleep 1; done

For HTTP services, poll the health endpoint:

until curl -fsS http://workstation:5000/healthz > /dev/null; do sleep 1; done

If your image lacks pg_isready (some slim Python images, for example), fall back to a Python loop that catches psycopg2.OperationalError and retries. The principle is the same: the setup script exits only when the next thing that will touch the service can actually succeed.

set -euo pipefail at the top of a check script is good defensive hygiene — until you start invoking subprocesses. Any unguarded python3 -c ..., curl ..., or psql ... that exits non-zero will trigger an immediate, silent bash exit. The platform then renders a generic "Something went wrong" modal that points at no specific cause. The script did not crash. It exited cleanly on a subprocess failure that bash decided to propagate, and the learner is given no path forward.

The fix is to wrap every subprocess call in an explicit error handler, so a clean failure produces a useful message instead of a void:

RESULT=$(curl -fsS "$API/healthz") || {
  fail-message "API health check failed. Is the service running on port 8000?"
  exit 1
}

The cost is roughly half a line per call. The payoff is a check script that surfaces a real error message when something goes wrong, instead of silently dropping the learner into a generic "we don't know what happened" UI.

A solve script's job is to produce the exact state that the corresponding check script validates, no more and no less, regardless of the state the host is in when the script runs. That means it must succeed when run twice (idempotent), and it must produce every condition the check script tests for (complete).

Idempotency is the more common failure. A solve script that runs useradd analyst will fail on its second run because the user already exists; the script either has to check first or use id analyst >/dev/null 2>&1 || useradd analyst. The same applies to file creation, schema changes, package installs, and any other state-touching operation. A reliable habit is to write every solve step as either "create if absent" or "ensure desired state" rather than "create."

Completeness is more often missed. A check script that verifies five separate properties — a user exists, has SELECT on table X, lacks DELETE on table Y, has a custom search_path, and is not a superuser — needs a solve script that establishes all five. A solve script that establishes four out of five passes informally on the author's machine and fails the actual check, often after a long debugging session.

A useful test: run the solve script twice on a fresh environment, then run the check script. If the check passes both times, the solve is correctly idempotent and complete.

The standard pattern for wrapping a system binary is to rename the original out of the way (mv /usr/bin/foo /usr/bin/foo-real) and write a wrapper script in its place. This works perfectly the first time. On the second run of the same setup script, the rename moves the wrapper to foo-real, and the new wrapper ends up calling itself. The result is infinite recursion the first time anyone invokes the binary, which often manifests as a process explosion that fills the host's process table.

The fix is one line: guard the rename with a check for the backup file's existence.

if [ ! -f /usr/bin/foo-real ]; then
  mv /usr/bin/foo /usr/bin/foo-real
fi

cat > /usr/bin/foo <<'EOF'
#!/bin/bash
exec /usr/bin/foo-real --extra-flag "$@"
EOF
chmod +x /usr/bin/foo

The wrapper itself is safe to rewrite on every run — only the rename needs guarding. This pattern composes with anything that needs an idempotent overwrite-once: replacing config files, swapping default editors, substituting libraries. Same [ ! -f <backup> ] guard, same one-line fix.

Files edited on a Windows-mounted filesystem can accumulate trailing null bytes — invisible 0x00 characters that don't show up in any normal editor view but break instruqt track push with a misleading UTF-8 error: invalid byte sequence for encoding "UTF8": 0x00. The error names UTF-8, but the actual cause is a stray null. The most common victims are track.yml, config.yml, and individual assignment.md files.

Strip nulls with a quick Python pass before pushing:

import pathlib
for p in pathlib.Path(".").rglob("*"):
    if p.suffix in (".yml", ".md") and p.is_file():
        b = p.read_bytes()
        if b'\x00' in b:
            p.write_bytes(b.replace(b'\x00', b''))
            print(f"stripped: {p}")

Run this any time instruqt track push errors with the UTF-8 message. Repeat after every editing session on a Windows host. The fix takes seconds; chasing the bogus UTF-8 error without knowing about it can take a quarter of an afternoon.

A track that creates costed external state — a managed serverless project, a SaaS tenant, an LLM API key with a budget, a per-participant GitHub identity, a GCP GPU VM, a managed-service trial org — owes the platform a corresponding cleanup-{hostname} that deletes it. Without one, every play of the track leaves behind a long-lived resource that nobody is watching. Orphans accumulate quietly. Costs accrue. Quotas fill. Eventually some operations engineer notices, hunts down the source, and discovers a track that has been bleeding for weeks.

The discipline is to write the cleanup script in the same session you write the provisioning code, not later. The provisioning script captures the resource's identifier with agent variable set; the cleanup script reads it with agent variable get and issues the delete.

# In setup, after creating the resource:
PROJECT_ID=$(curl -fsS -X POST "$API/projects" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"name":"lab-'"$INSTRUQT_PARTICIPANT_ID"'"}' | jq -r '.id')
agent variable set ES_PROJECT_ID "$PROJECT_ID"

# In cleanup-{hostname}:
PROJECT_ID=$(agent variable get ES_PROJECT_ID)
curl -X DELETE "$API/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN"

If the cleanup script is hard to write — branchy, conditional on which provisioning step succeeded, dependent on parsing complex API responses — the design has accumulated complexity that will hurt future maintenance. A well-shaped track has a cleanup that is mechanical: read N agent variables, issue N delete calls, exit zero.

If you can't cleanly delete the resource at all — the platform doesn't support deletion via API, the resource has no stable identifier, the cleanup requires manual approval — that's a strong signal to choose a different resource type or to scope the track differently.

Two malformed bash header lines turn set -euxo pipefail into nothing. Both produce silent failure modes that are difficult to diagnose because the script appears to run normally — the absent error-exit and trace behavior just lets failures pass through invisibly.

The first trap is #set -euxo pipefail — note the leading #. The line is a comment. The shell never executes the set builtin. Without -e, intermediate failures don't abort the script. Without -x, you can't see what went wrong from the trace. The script appears to complete; the actual end state is whatever happened to survive. This bug copy-pastes easily: when the shebang line above is #!/bin/bash, a hand that adds set -euxo pipefail immediately below sometimes copies the leading #.

The second trap is set -eux pipefail — missing the -o flag. The set builtin reads pipefail as a positional parameter, not as an option name. Only -e, -u, and -x are actually applied. Pipeline failures (curl ... | jq ... where curl fails) pass through unflagged. Setup proceeds on partial data. Check scripts pass on data that wasn't actually generated.

Both bugs show up in the wild often enough to warrant their own grep:

grep -rn "^#set -" track_scripts/ */setup-* */check-*
grep -rn "set -eux pipefail$" track_scripts/ */setup-* */check-*

Run these before every push. The fix in each case is one character: drop the #, or add o after -eux. Detection takes seconds. The diagnostic time when the bug is live in production can run a quarter of a day.

A check script's job is not to determine pass or fail. Its job is to teach the learner what they got wrong and how to fix it.

The default pattern — chaining every assertion into one expression and emitting a single generic message on failure — satisfies the platform's contract but fails the learner. They see "User is not configured correctly," they re-read the assignment, and they run the same SQL again. This is where learners abandon tracks.

The granular pattern runs each assertion independently, and on each failure emits a specific remediation message that names the exact command the learner needs to run:

if ! psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='analyst'" | grep -q 1; then
  fail-message "User 'analyst' does not exist. Run: CREATE USER analyst WITH PASSWORD 'changeme';"
  exit 1
fi

if ! psql -tAc "SELECT has_table_privilege('analyst', 'orders', 'SELECT')" | grep -q t; then
  fail-message "User 'analyst' lacks SELECT on orders. Run: GRANT SELECT ON orders TO analyst;"
  exit 1
fi

That second assertion's message — naming the exact GRANT statement to run — converts the Check button from a verdict into a teaching tool. A track that takes the time to write specific failure messages is a different product than one that does not.

The cost is real: five well-written assertions take longer to write than one chained expression. But check scripts are some of the highest-leverage prose in the entire track. A learner reads the assignment once. They read failed check messages every time they iterate. Spend the time. (See Pattern Cookbook Recipe 36 for the full granular check pattern, including auto-counted assertions and side-effect-triggering checks.)

fail-message and set-status are not universally available. An earlier rule of thumb — "container-only, not on VMs" — was a useful approximation but understates where the helpers exist. They ship on every Instruqt-published container image, on every Instruqt-published custom VM image, and on every stock cloud Linux image (the agent installs during bootstrap). They are also reliably present on most vendor-published custom VM images that bake the Instruqt tooling in — vendors that maintain workshop-specific image families typically include the helpers as part of their base build.

Where the helpers genuinely don't exist is on raw upstream Docker base images: stock ubuntu:22.04, python:3.11-slim, node:22-bookworm, and similar. A check script invoking fail-message on one of those exits 127 with no useful error, and the platform renders a generic "Something went wrong" modal.

The fix is to know the image you're using and guard accordingly. On any image where helper availability is uncertain, defend with || true:

fail-message "API not reachable. Run: ./start-api.sh" || true
exit 1

When you've decided to skip the helper entirely, fall back to plain echo and exit 1:

if ! systemctl is-active --quiet myservice; then
  echo "myservice is not running. Run: systemctl start myservice"
  exit 1
fi

The styled UI hint is lost, but the message surfaces in the Instruqt debug log and the script fails clean instead of mysteriously.

A useful heuristic: image is custom-built for Instruqt workshops (often shows a Packer-build marker like packer- in the name) — almost always present. Stock cloud Linux image — present, via bootstrap. ubuntu:22.04 or python:3.11-slim from Docker Hub — almost always absent. When in doubt, a one-line fail-message "test" in a check script will exit 127 if the helper isn't there. Five-second test, prevents days of debugging.

Resources in many tracks progress through multiple states: ingested → classified → enriched → governed; provisioned → configured → tested → deployed. A check script that asserts a specific intermediate state — for example, status == "enriched" — will produce false negatives on resources that have legitimately advanced further. The learner's correct work fails the check because it advanced too far.

The fix is to validate the intent of the assertion, not a single state value. Accept all valid terminal states for the check's intent. If the check is meant to verify that enrichment happened, accept any state at or beyond enrichment; do not insist on the exact label enriched.

case "$STATUS" in
  enriched|governed)
    # both indicate enrichment occurred
    ;;
  *)
    fail-message "Resource has not been enriched yet."
    exit 1
    ;;
esac

The same logic applies to data-driven checks. A query that compares the learner's submitted answer to a hardcoded constant breaks when the seeded dataset is rebaked or the source data drifts. Use the live-truth pattern instead: re-execute the same query the learner ran against the live data source, and compare to their submitted value. The check is now self-correcting against any future change to the source.

The general form: a check script's pass condition is a property of the resource or a property of the data, not a position in a state machine or a constant baked into the script. Express it as the property.

Most check scripts emit a binary verdict: pass or fail. For certification or assessment-style tracks, this is the wrong abstraction. A learner who has met seven of eight criteria gets the same response as a learner who has met none, and they have no way to identify what specifically remains.

A scored rubric replaces the binary verdict with a transparent point-based tally. Each assertion is worth N points; the script accumulates a score as it runs; the final output is a color-coded scorecard before the script exits 0 or 1 based on a passing threshold.

SCORE=0
TOTAL=100

if check_assertion_one; then
  SCORE=$((SCORE + 15))
  echo "[PASS] +15 — Resource was tagged correctly"
else
  echo "[FAIL]   0 — Resource was not tagged. See section 2."
fi

# … repeat for each criterion …

echo
echo "Final score: $SCORE / $TOTAL"
[ "$SCORE" -ge 70 ] && exit 0 || exit 1

The cost is a more complex script. The benefit is that learners who fall short know precisely which two of the eight criteria are still missing and can target their second attempt instead of starting from scratch. Certification becomes a coachable scorecard rather than a black box. (See Pattern Cookbook Recipe 37 for a full scored rubric implementation including color coding and per-criterion remediation prose.)

A challenge that drops the learner into an editor with a single starter file gives them only one path forward: figure it out or get stuck. The companion-code-tab pattern fixes this with almost no extra effort. Add a second code tab next to the editable file, pointing at a completed reference implementation written by the setup script:

tabs:
  - title: Edit
    type: code
    hostname: workstation
    path: /root/report.py
  - title: Reference
    type: code
    hostname: workstation
    path: /root/report_complete.py

Stuck learners can open the reference, see the shape of the answer, and return to their own implementation. The pattern works because the reference is visible but not forced — it is a hint the learner reaches for, not a spoiler the platform shoves in their face. Time-on-track drops; completion rates rise; and the learner walks away with both their own work and a known-good reference for later.

A small refinement: name the reference file something the learner will recognize as "the answer" (report_complete.py, solution.py) rather than something cryptic. The discoverability is the point. (See Pattern Cookbook Recipe 31 for the full companion-code-tab implementation.)

A website tab pointing at an http:// URL will cause instruqt track push to fail at validation. The failure mode is silent enough at the platform level that the cause is not always obvious from the error output. This rule is worth its own line in the booklet because the failure is silent and the fix is trivial: every URL in every website tab must be HTTPS.

tabs:
  - title: Vendor docs
    type: website
    url: https://docs.example.com   # never http://

This applies even to URLs that "obviously" support HTTPS — the validator does not negotiate. If the destination genuinely cannot be served over HTTPS, switch to a service tab (which uses Instruqt's certificate provisioning) or an external tab (which is more permissive). Or fix the destination, if you control it. Do not push a track with an http:// website tab and hope.

Challenge directories at the track root must be numbered with a leading two-digit prefix — 01-, 02-, 03- — and the order must match the challenge sequence in track.yml. Numbering is more than cosmetic. Gaps in the sequence (01-, 02-, 04-) or mismatches between directory order and track.yml order will produce undefined behavior at push time, sometimes erroring, sometimes silently shipping a track with the wrong sequence.

When inserting a challenge in the middle of an existing track, renumber every subsequent challenge to keep the sequence dense. When deleting a challenge, renumber the rest. Some tracks use a double-number format (01-01-, 01-02-) for sub-grouped challenges; this is fine, as long as the prefix sort order matches the intended sequence.

The id field assigned by Instruqt is permanent and unaffected by renumbering — only the directory name changes. Treat the leading numbers as ordering metadata that belongs to the filesystem, not to the challenge itself.

Even a perfectly designed track can be broken by a learner who skips ahead, hits the "skip" button, or encounters a transient infrastructure failure that leaves a previous challenge's state incomplete. A challenge that depends on state established by an earlier challenge — a record in a database, a file on disk, a configuration applied to a service — is fragile if it assumes that state is always present.

A defensive challenge-level setup script idempotently re-creates the state the previous challenge was supposed to leave behind, so the current challenge starts in a known-good condition regardless of what happened before:

#!/bin/bash
set -euo pipefail

# Re-create the customer record the previous challenge should have created
COUNT=$(psql -tAc "SELECT count(*) FROM customers WHERE name='Acme Corp';")
if [ "$COUNT" -eq 0 ]; then
  psql <<SQL
INSERT INTO customers (name, plan, created_at)
VALUES ('Acme Corp', 'enterprise', NOW());
SQL
fi

The pattern is: query first, create only if absent. The cost is a few lines per dependent challenge. The payoff is a track that holds up against the messy reality of how learners actually move through it. (See Pattern Cookbook Recipe 32 for the defensive challenge-level setup pattern in full.)

A fenced code block in assignment.md is more than syntax highlighting. The language tag tells Instruqt's renderer what to do with the block — particularly when paired with the run modifier. `python,run invokes Python on the block's contents. `bash,run invokes bash. The wrong tag produces wrong execution, and the failure mode is invisible to the reader because the rendered output looks correct.

The most common version of this bug is a Python tag on a shell block:

# Reads as bash to the reader, but if the fence is ```python,run
# instead of ```bash,run, clicking the run button hands the line
# to a Python interpreter — which produces a SyntaxError.
ls -la /workspace

The fix is matchmaking. Use `bash,run for any block executing in a shell. Use `python,run only when the block actually is Python. Use `run (no language) for language-agnostic runnable blocks where the active terminal will figure it out. Use plain `bash (no run modifier) for snippets the learner is meant to read but not execute — for example, a bash-flavored example containing placeholders the learner must edit before running.

A related trap: `bash {"run": "execute"} looks like it should work. It does not. The block renders, but no run button appears. Instruqt's modifier syntax is comma-separated after the language tag — ,run, ,copy, ,nocopy,run — and the JSON-style annotation is silently invalid. Always use the comma form.

Authors who push frequently and test challenge by challenge eventually ship a track that works for every individual challenge but fails when run end to end. The cause is almost always state — challenge two assumes something challenge one set up, and the learner hits the discontinuity even though the author never did, because the author tested challenge two starting from a state they manually established.

Before any push that will reach a learner, run the full track from start to finish in a fresh environment. Click through every challenge in order. Use the solve button on each one if you have to, but make sure the entire sequence completes without manual intervention between challenges. The bugs you find this way are almost never the bugs you find by testing in pieces.

This is the single highest-value test you can run, and it is also the test most authors skip because it takes the longest. Skip it only when you are willing to ship a track you have not actually finished.

Edits made on a Windows-mounted filesystem can introduce defects that do not exist in the source as you see it: trailing null bytes, line ending mismatches, encoding issues, and occasionally permissions glitches that survive the next push. The author opens an editor, makes a one-line change to fix a typo, pushes, and the track suddenly fails to push at all — or worse, pushes successfully but breaks at runtime.

The defensive habit is to treat any edit on a Windows host as a potential source of corruption and re-run the validation steps that catch it: strip null bytes (Principle 11), normalize line endings if your tooling does not do it automatically, and run instruqt track push against a non-production version of the track to confirm the change actually validates. None of these steps is expensive on its own. The expensive thing is shipping a broken track because you trusted that a one-line change couldn't have introduced anything.

If your work environment is a Windows host and your track repository lives on a mounted filesystem, build the validation pass into your habits rather than your memory. Your future self will thank you.

A track in active development accumulates decisions, deferred work, and gotchas faster than any author can remember. The defenses against this are well known — commit messages, READMEs, comments in setup scripts — and none of them quite fit the shape. Commit messages are short and tied to specific changes. READMEs describe the track to a reader. Comments live inside code. None of them captures "I tried X and abandoned it because Y" or "this works only because Z — revisit if Z changes."

A MEMORY.md file at the track root, treated as a living build log, fills that gap. The shape that holds up:

# MEMORY — <track-slug>

Status: <one-line>

## Session History
### Session N — <date>
- One bullet per meaningful event.

## Deferred Work
- Items noticed but not yet shipped.

## Track Architecture Notes
- Topology, key files, gotchas baked into this track's design.

Update it after every meaningful track change — fixes shipped, decisions made, deferred work identified. The cost is a few sentences. The benefit is that picking up a paused track three weeks later starts from a real briefing rather than a cold scroll through commit history.

Two sections earn their own place. Deferred Work captures things you noticed but did not fix in this session, so they do not get lost. Session History captures the why behind decisions, so future-you can audit your own reasoning rather than re-derive it.

The discipline is not about thoroughness — it is about ratchet. Once you write things down, you stop re-discovering them. The build log accumulates rather than the build memory.

Twenty-five is not the right number because the world contains exactly twenty-five principles for building Instruqt tracks. Twenty-five is the right number because the source these principles draw from carries the lessons that earned a place — and every lesson on this list had to earn it against every other. Above thirty, the pressure to be selective relaxes and filler creeps in. Below twenty, the list misses real ground. Twenty-five is where the rules feel chosen and the source feels covered.

Read this booklet straight through the first time. Then come back to single principles when you have a specific problem in front of you — a check script that is not behaving, a setup script that runs once and breaks the second time, a push that fails with a message that does not make sense. The principles are short on purpose: each one is a thing you can hold in your head and apply at the moment you need it.

Some of these rules will feel obvious. Some will feel like overkill until the day they save you. Some you will disagree with — and disagreement is fine, as long as you have thought about it. The point of a best-practices booklet is not to settle every debate but to make the debates explicit so they can be had.

If you build tracks long enough, you will find your own rules — habits you grew into through your own scars. Add them. Cross out the ones here you have outgrown. The list is not sacred. The discipline of having a list is.