A deep technical tour

Preview Fabric

Disposable, per-task dev environments on one shared Kubernetes host — driven live from your local git worktree

Structure: swipe for the next module, for depth within a module.
Written for a senior SWE with no Kubernetes / infra background — K8s concepts are introduced as K8S callouts when first needed.

The problem it solves

  • The real app (mission-control-workspace) is 2 repos, 34 services, 11 volumes, 2 PostgreSQL databases — far too big to run on a laptop.
  • A single shared staging env means devs overwrite each other; VMs per dev rot and drift.
  • Preview Fabric: every task branch gets its own complete, isolated, disposable copy of the whole stack on one beefy shared host, kept in sync with your local uncommitted edits in near-real-time.
  • It is a control plane: a CLI + local daemons on your laptop, and two small programs on the cluster (an operator and a source agent).
Headline numbers (live-validated 2026-08): create a full 34-service feature environment in ≈ 1 min 50 s; a hot code edit reaches the running pod without any restart; smoke checks 8/8.

What using it feels like


./preview-local bootstrap                      # one-time: builds its own venv
./preview-local cluster configure --host … --user …
./preview-local project configure mission-control-workspace \
    --definition …/mission-control-workspace.json \
    --component mission-control=…/mission-control …

./preview-local dev session my-feature \
    --project mission-control-workspace \
    --component mission-control=…/task-worktree --background
# … edit code locally; changes stream to the running env …
./preview-local dev urls my-feature            # http://…:8080/… via SSH tunnel
./preview-local dev smoke my-feature           # scripted HTTP checks
./preview-local dev down  my-feature           # stop (state kept)
./preview-local dev purge my-feature           # destroy everything, with receipts
    

Everything below this slide explains what those commands actually do.

Module 1

Kubernetes in ten minutes K8S

Just enough Kubernetes to read the rest of the deck. If you know K8s, swipe right.

Kubernetes (here: k3s) — a single-binary lightweight K8s distribution running on the one shared host. Think of it as: a REST API server backed by a database of desired-state records ("objects"), plus controller processes that continuously push reality toward what the records declare.
  • You never "run a command" to start things; you write an object (YAML/JSON) and a controller notices.
  • Objects have metadata (name, labels = indexed key/value tags, annotations = unindexed notes), a spec (desired) and a status (observed).

Workload objects K8S

Pod — the unit of execution: one or more containers sharing network + volumes. Pods are cattle: they die and are replaced, never repaired.
Deployment — "keep N identical stateless pods running"; rolling updates by replacing pods. Used for app services.
StatefulSet — like a Deployment but pods get stable names and their own persistent volumes. Used for databases (postgres, neo4j, rabbitmq).
Job — "run this pod to completion (possibly retrying)". Used for dependency installs, database clones, validation checks. Kubernetes guarantees at-least-once, not exactly-once — a theme later.

Storage, networking, isolation K8S

Namespace — a named scope grouping objects; most objects live in exactly one. Preview Fabric creates one namespace per environment.
PVC (PersistentVolumeClaim) — a request for durable disk that survives pod restarts. Backing storage here is local disk on the single host.
hostPath volume — mounts a directory of the host machine directly into a container. Normally frowned upon; used deliberately here for source worktrees and the dependency cache.
Service — a stable virtual IP + DNS name load-balancing to pods selected by labels.
Gateway API / HTTPRoute — the modern ingress: a shared Gateway (here: Traefik) terminates HTTP, and each app publishes HTTPRoute objects ("hostname X, path Y → service Z").
NetworkPolicy — firewall rules between pods, expressed with label selectors.

The CRD + operator pattern K8S

CRD (CustomResourceDefinition) — registers a brand-new object type into the API server, with an OpenAPI schema and validation rules (CEL expressions). Preview Fabric adds one: PreviewEnvironment.
Operator / controller — a program that watches objects of some kind and runs a reconcile loop: read desired spec → read actual world → do a bounded amount of work to close the gap → write status → repeat.
  • Level-triggered, not edge-triggered: it acts on current state, never on "the event", so missed/duplicated events are harmless. Think fixpoint iteration, or React's render loop, not a message handler.
  • Reconciles must be idempotent — they rerun constantly, on any change, on restart, on a timer.
  • Leader election: multiple copies may run for availability; a lock object (Lease) ensures only one reconciles at a time.

Talking to a cluster K8S

kubectl — the CLI for the API server: kubectl get/create/apply/delete …. Reads a kubeconfig file for the server address + credentials.
kubectl exec -i — runs a process inside a running container, with stdin/stdout piped through the API server. Effectively "ssh into a pod", and — important later — it can carry an arbitrary binary protocol.
  • In this system the laptop has no kubeconfig and no network route to the API server at all. Every kubectl runs on the shared host, over SSH. One credential (your SSH key) is the entire security model.

ssh -F /dev/null -o BatchMode=yes -o StrictHostKeyChecking=yes user@host \
  '/usr/bin/env KUBECONFIG=… kubectl get previewenvironments …'
    

Ownership & garbage collection K8S

ownerReferences — the standard K8s parenting mechanism: object B lists object A as owner; delete A and the GC deletes B automatically ("cascading delete").
Preview Fabric refuses this mechanism entirely. Every object it creates is ownerless; identity is proven by an exact set of labels + annotations + recorded UIDs, and deletion is always explicit, preconditioned, and provable. A non-empty ownerReferences on "its" object is treated as evidence of a foreign collision.
  • Why: cascading GC deletes by name-based linkage; this system wants deletes to require proof (exact UID + resourceVersion), so a stale or colliding actor can never destroy the wrong thing.
  • UID — every object gets a unique immutable ID at creation; a re-created same-name object has a different UID. Preview Fabric leans on this constantly.
Module 2

The big picture

Laptop

CLI preview-local
Environment Manager
1 daemon per alias · IPC · SQLite journal
Watcher session
inotify → coalescer
Tunnel supervised ssh -L

SSH
only

Wire (one SSH trust domain)

remote kubectl → CR + Secrets + status
kubectl exec -i → source agent
framed bytes: git bundle + overlay
ssh -L loopback tunnel → HTTP

Cluster (k3s host)

PreviewEnvironment CR
durable desired state
Operator reconciles → ns, PVCs, workloads, Jobs, routes
Source agent materializes worktrees on hostPath
State cache warm dependency volumes

Everything user-initiated flows left→right. The operator alone touches runtime objects; the client never creates a pod.

Who owns what — the seven authorities

AuthorityOwns
Git repositoriescommitted source history
Your worktreethe current dirty (uncommitted) source
preview-source-agentserver-side source materialization (worktrees on the host)
Environment Manager (per alias)lifecycle/source coordination + its private recovery journal
PreviewEnvironment CRdurable desired runtime intent
preview-fabric-operatorthe only durable runtime reconciler
Kubernetescurrent workload / network / Secret / Job / PVC reality

Every design decision below is some form of: keep these authorities from stepping on each other without introducing new identity or auth systems.

One trust domain, zero new auth

  • There is exactly one security boundary: SSH to the shared host (and the kubeconfig it grants). Preview Fabric adds no HTTP endpoints, users, tenants, OAuth/OIDC, bearer tokens, or app-level authorization.
  • installationID (e.g. preview-fabric-system) is a collision scope, not identity — it namespaces object names so two installations can share a cluster.
  • Inside the domain, safety is logical, not authn: exact labels, recorded UIDs, writer fences, receipts, and compare-and-swap everywhere.
Writer fence — a 4-tuple (ownerID, ownershipEpoch, incarnationID, incarnationSequence) naming exactly which manager process may mutate an environment. Stale processes are "fenced": their writes are rejected by both the source agent and the CR schema. Cooperative fencing, not authentication.

The seven state spaces

StoreSchemaContains / never contains
Project definition (tracked in repo)project/v4components, services, tiers, volumes, secrets decl., databases
cluster.json (machine)cluster/v1SSH host/user, ports, installationID — no secrets
Client binding (per alias)client-binding/v1paths, receipts, watcher identity — never source bytes/secret values; 512 KiB cap
Manager journal (per alias)SQLite WALrequest/receipt identities for crash recovery — never a workflow program counter
Source agent store (host)source/v2bare repos, worktrees, receipts, outcome log, tombstones
PreviewEnvironment CRv1alpha1complete runtime intent + bounded observed status
State cache (host)dirs + markerfinished dependency volumes, content-addressed — no owner, no reconciler
Module 3

Client anatomy

  • ./preview-local is a 96-line self-bootstrapping Python launcher: creates .preview-local-venv/, installs pinned deps (requirements/runtime.lock), then os.execv-re-executes itself inside the venv.
  • Deliberately tiny runtime deps: anyio, pydantic, watchfiles, tzlocal, idna. ~27 k lines of Python across 34 modules.
  • Three kinds of local process, all supervised:
    • Environment Manager — one daemon per alias; the only thing that mutates cluster state.
    • Watcher session — feeds source observations to the manager.
    • Tunnel — one shared ssh port-forward per installation.
Supervision backendssystemd-run --user (Linux), launchd (macOS), or a plain detached-process fallback. Process identity is (pid, boot-id, start-ticks) so a recycled PID is never mistaken for a live worker.

CLI map

GroupCommands
setupbootstrap · doctor · cluster configure/status/previews/image-load/tunnel · project configure/list/status
lifecycledev create / up / down / purge / sync / session / restart / lease
observedev status / urls / smoke / logs / diagnose / manager-status / gc
escape hatchesdev shell / forward / remote publish / secrets apply / clone retry / takeover
baselinedev main up / down / ensure / status / reset
low-leveldev plan / ensure / wait (the raw protocol, exposed)
  • Global: --json for machine output, --state-root, --config-root. Mutating commands accept --detach; default wait timeout 120 s.
  • An alias (e.g. my-feature) is the developer-facing name of one environment; everything else derives from it.

Configuration layers

cluster.json — the machine's connection: host, user, ssh port, identity file, remote kubeconfig/kubectl paths, ingress_node_port 30080, local_preview_port 8080, gateway names, installation_id. Fail-closed validation on every field.
projects/<id>.json — machine-local registration pointing at the tracked project definition, mapping each component to its canonical main worktree + branch ref, secret path overrides, default tier.
RuntimeContext — the resolved in-memory view for one operation: cluster + definition + component paths (a task worktree can override the main path at create time only) + secret paths + tier. Never serialized.
  • State root (.preview-local-state/): client-owner ID, per-alias binding (bound paths, last receipts, watcher identity), flock files, manager journal + IPC sockets.
  • Bindings never store cluster truth — the CR is the source of truth for lifecycle; local state only holds what the cluster cannot reconstruct.

The Environment Manager

  • One supervised daemon per alias; a flock singleton guarantees uniqueness. CLI talks to it over a Unix domain socket (preview-fabric-manager-ipc/v2, length-prefixed canonical JSON ≤ 1 MiB, peer-uid checked, 8 worker threads).
  • It owns all cluster mutation: CR writes, secret application, source-agent calls. The CLI itself never touches the cluster.
  • Its SQLite journal (WAL, FULL sync) records request/plan/receipt identities before any side effect — enough to resume an interrupted operation exactly, never a workflow engine.
Plan
pure, read-only
classify: runtime-activation · source-only · no-op · purge
Ensure (once)
journal identities → side effects
returns immutable causal receipt
Wait
observe receipt vs gates
source | runtime | validated
Diagnose
read-only explain

A timeout never retries by itself; an interrupted Ensure is resumed under its original identity, not re-planned. This is the idempotency spine of the whole client.

Journal phases & recovery


Prepared
  -> FenceClaimed                 # writer fence current on source agent
  -> SourcePartiallyAccepted | SourceAccepted
  -> APIAccepted                  # CR write accepted by API server
  -> OperatorAcknowledged         # operator echoed the proposal
  -> Terminal
    
  • On restart the manager always finishes the journaled request first — replaying the stored Plan under its original request ID — before planning anything new.
  • Every remote effect is keyed: request ID + payload digest + dedupeUntil. A lost response is resolved by querying the retained outcome, adopting only the exact recorded result.
  • Receipts flow back to the CLI so wait can distinguish my convergence from someone else's (Succeeded / Superseded / Blocked / PLAN_STALE / Deleted…).
Module 4

Watching your worktree

  • Built on watchfiles (Rust notify backend → inotify on Linux: the kernel pushes file-change events; no scanning).
  • The watch set is non-recursive and policy-pruned: the client walks each component root once, registers one native watch per retained directory, and skips .git, git-ignored trees (node_modules…), declared watch_excludes, secret paths, .previewignore matches. Budget: 100 000 watches.
  • New paths are classified live with git check-ignore; creating a directory or editing .gitignore/.previewignore triggers a full watch-set rebuild.
  • WSL nuance: watchfiles force-polls on WSL by default; the client parses /proc/self/mountinfo and re-enables inotify when all roots are on native Linux filesystems.
Watch vs source are separate policieswatch_excludes only suppresses triggers; a later manual sync can still deliver those paths. .previewignore excludes from both.

Computing the dirty overlay

  • On each trigger, the client asks git what is actually dirty:
    • git diff --name-only HEAD → tracked edits
    • git ls-files --others --exclude-standard → untracked files
    • plus explicit include_ignored force-includes
  • Existing paths → overlay_paths (content to ship); missing → deleted_paths; committed drift vs the last-shipped HEAD → changed_paths (observational).
  • A deterministic fingerprint = sha256 over HEAD + each path's (mode, size, content-hash) — lets everything downstream detect "same state" cheaply.
Everything is bounded. Overlay ≤ 8192 paths; changed-path inventory ≤ 4096 paths / 64 KiB — beyond that it collapses to the synthetic path ".", which downstream reads as "do a full reconciliation". Overflow degrades to a slower-but-correct path, never an error.

The latest-state coalescer

  • Problem: shipping source takes seconds; you keep typing. Naive queues grow unboundedly and replay stale states.
  • Solution: a thread-safe slot holding exactly one pending observation. New events merge into the slot (latest state wins); submit() never blocks the watcher.
  • Per publish cycle: ship current state, then at most one follow-up for whatever accumulated meanwhile. Sequence reservation stops an old slow validation from regressing a newer state.
  • Overflow of the slot's bounds sets full_reconciliation_required instead of growing memory.
Why not an event log? Only the latest worktree state matters — intermediate states are worthless. Coalescing gives O(1) memory and automatic backpressure. Explicit user actions (restart, retry) travel a separate "intent lane" and can never be coalesced away.

The session loop

Start SourceWatch first (nothing is missed), claim the writer fence via manager IPC.
One full catch-up sync for edits made while offline (a detached Ensure).
Register watcher identity in the alias binding — a second concurrent session is refused unless the first is provably dead.
Loop: poll events → 200 ms debounce → merge into coalescer → publisher thread calls source-submit + source-publish (the hot-reload lane).
If the manager answers RUNTIME_AFFECTING_SOURCE_REQUIRES_ENSURE (the edit touched declared config/dependency inputs), escalate to a full sync, exponential backoff ≤ 60 s.
dev down clears the watcher token in state; the loop notices and exits cleanly.

The watcher is a pure input producer: it can keep ingesting while the operator is slow, because the source lane and the runtime lane are independent.

Module 5

Shipping source: the exec-only agent

  • preview-source-agent is a Go binary in a 1-replica Deployment. Its long-running container just sleeps (daemon verb). It mounts one hostPath: /var/lib/preview-fabric/source.
  • It has no Service, no port, no listener, no service-account token. There is nothing to scan, nothing to authenticate, no API surface to version behind a load balancer.
  • Every request is one fresh process:
    
    ssh user@host -- 'kubectl exec -i -n preview-fabric-system \
      deployment/preview-source-agent -- /preview-source-agent serve'
          
  • One process = one framed request on stdin, one framed response on stdout, exit. Client-side timeout 900 s. The SSH/kubeconfig trust domain is the auth.

Wire format: preview-fabric-source/v2


PFSOURCE/2\n                      # magic
[u32 big-endian header length]
{ canonical JSON header, ≤ 1 MiB, unknown fields rejected }
[bundle bytes  … declared size + sha256]
[overlay bytes … declared size + sha256]
    
  • Header carries: operation, transaction ID, request ID + dedupeUntil, writer fence, component, repository key, monotonic sequence, expectedReceipt (CAS), commit, sparse patterns, overlay/deleted/required paths, semantic inputs.
  • Operations: ingest · status · publish · remove · claim · takeover · writer-status · outcome · purge · purge-status.
  • Bounds: bundle/overlay ≤ 8 GiB each, ≤ 20 000 paths, receipts ≤ 4 MiB. v1 magic is recognized only to say unsupported-protocol — before any store initialization.

Payload = git bundle + tar overlay

git bundle — git's file format for "a set of commits + their objects", normally used for offline transfer. Here it carries committed history; a PAX tar carries the dirty overlay files.
  • Bundles are built deterministically (pack.window=0 pack.depth=0 pack.threads=1) — the bytes are hashed into the receipt, so they must be reproducible.
  • Incremental bundles (the shipped transfer speedup): writer-status advertises up to 32 repository tips the agent already has; the client builds git bundle create <rev> --not <tips> — a new env at the baseline tip ships a ~642-byte bundle instead of full history.
  • The agent parses the bundle header's prerequisites itself and fails bundle-basis-unknown before touching anything; the client retries the same request ID with a full bundle. Tip presence doubles as the capability signal — old agents reject the unknown header field.

Server pipeline: stage → validate → finalize

Read both payloads completely into private staging; verify framing, lengths, SHA-256s, end-of-input.
Verify + import the bundle in an isolated throwaway repo (borrowing shared objects read-only via objects/info/alternates); git runs with hooks and credentials disabled, auto-gc off.
Detached sparse checkout at the requested commit → apply overlay tar (every path declared, safe-relative, no .git, symlinks confined) → apply deletions → check required paths.
Compute semantic fingerprints: sha256 over named files/dirs (e.g. lockfiles) — these later drive dependency-Job revisions.
Finalize: converge the live worktree file-by-file with atomic renames at
SOURCE_ROOT/worktrees/INSTALLATION/ENV_UID/COMPONENT — same path, new bytes. This is what makes hot reload free: pods mount that path; no K8s object changes.
Write receipt = sha256(request digest, unpublished commit, semantic fingerprints); append terminal outcome.

Concurrency safety, in layers

MechanismProtects against
Writer fence (owner/epoch/incarnation/seq); explicit takeover onlya stale manager process writing after a new one took over
Lock order: environment → component → repository (flock)a takeover landing between fence-check and finalization
Receipt CAS (expectedReceipt) + monotonic sequencelost-update races; out-of-order delivery
Outcome log keyed (request ID, digest), ≤ 24 h dedupe, 2 GiB budgetreplays after lost responses → returns the original outcome (alreadyApplied)
Same ID + different payload → request-id-reusedaccidental identity reuse
Retired-UID tombstones (forever)a zombie manager resurrecting a purged environment
Capacity exhaustion rejects before mutationevicting an unexpired idempotency promise

Store layout & the reverse path


/var/lib/preview-fabric/source/
├── repositories/INSTALLATION/REPOKEY.git    # one shared bare repo per repo
├── worktrees/INSTALLATION/ENV_UID/COMPONENT # ← pods mount exactly this
├── receipts/  writers/  retired/  purges/
├── outcomes/          # 256-shard append-only idempotency log
└── .staging/  .finalizing/  locks/
    
  • Remote commits flow back too: if an agent (human or AI) working inside the env's dev-shell commits to the server worktree, the next ingest preserves that commit under an unpublished ref before overwriting.
  • dev remote publish ALIAS COMPONENT asks the agent for a bundle of it and imports it locally at refs/preview-fabric/<alias>/<component> — no checkout, no merge, no branch change. Normal git workflow takes over from there.
Module 6

The PreviewEnvironment CRD

  • Group preview.preview-fabric.io/v1alpha1, kind PreviewEnvironment, cluster-scoped (lives outside any namespace) — so the operator can derive and own the environment's namespace and detect collisions before creating anything.
  • Deterministic identity chain:
    
    CR name:    pe-<alias>-<sha256(installation┃project┃alias)[:10]>
    namespace:  pf-<inst≤11>-<proj≤16>-<alias≤20>-<hash(…+CR-UID)[:5]>
            
    The namespace hash includes the CR UID → a re-created same-name environment gets a different namespace; the old one is left untouched.
  • The spec is the complete runtime graph — the operator needs no other input: sources, services, PVCs, routes, network policies, database clones, checks, lifecycle protocol.

Lifecycle v2: birth of an environment

  • Creation starts as a Claiming skeleton: protocolVersion: v2, initialization phase Claiming + a random client-binding nonce + claimExpiresAt (15 min), desired state Stopped, and — enforced by CEL — zero sources/services/PVCs/routes/clones.
  • A skeleton creates no namespace and no runtime child. It exists only to reserve identity and let the writer fence be established on both planes (source agent + CR).
  • The manager then flips Claiming → Active (one-way) together with the first complete runtime proposal.
  • Recovery of an interrupted create requires the exact nonce + environment UID; a same-name leftover or expired skeleton is never adopted.
runtimeProposal — the handshake payload: ensureRequestID, planID, activationID (hashes of the plan), dedupeUntil, and the source receipt vector (component → receipt). CEL guarantees an ID can only ever be reused with a byte-identical payload.

Spec tour — the parts that matter

FieldWhat it declares
installationID, alias, project{id, role, baselineRef, tier}identity + Feature vs Baseline role (immutable)
lifecycle{writer, initialization, runtimeProposal, serviceActions, cloneActions}the v2 cooperating-writer protocol
desiredStateRunning | Stopped — the only imperative-ish knob
lease{expiresAt, pinned, retention}absolute expiry; reconciled in-cluster even with all clients offline
sources[]per component: worktreePath (the only field entering PodSpecs) + receipt/commit/fingerprints (observational)
services[] (≤128)type Deployment/StatefulSet/Job, full PodTemplate, ports, sourceMounts, pvcMounts, stateCacheMounts, configFingerprints, dependencyFingerprints, dependencies (Started/Ready/Completed, propagateRevision), externalEffect contract for Jobs
pvcs[] routes[] networkPolicies[] databaseClones[] applicationChecksstorage, HTTP routing, isolation, data cloning, post-convergence validation

Status: bounded observation, never history

  • status.observedGeneration == metadata.generation means "the operator has seen this spec" — not that it succeeded. (generation increments on every spec change.)
  • Phases (summary only): Pending → Reconciling → Ready / Stopped, plus Degraded, Blocked (ownership conflict), GarbageCollected, Terminating.
  • Conditions (the real signal): ProposalAccepted · Ready · Progressing · Degraded · OwnershipConflict · LeaseExpired · DatabaseClonesReady · RoutesReady · Validated.
  • lifecycle.currentTarget / lastAppliedTarget: the activation being converged, with expected child revisions — what a precise wait correlates against.
  • Terminal outcomes ledger: ≤ 128 unexpired results keyed by activation/action ID — the cluster-side half of exactly-once semantics.

Schema-enforced protocol (CEL)

  • ~20 validation rules run inside the API server on every write — even a buggy client cannot corrupt protocol state:
RuleEffect
installationID, alias, project id, nonce immutableidentity can never drift
ownership epoch advances by exactly +1; ownerID immutable within an epochtakeovers are explicit and ordered
incarnation sequence strictly monotonicstale process writes rejected at the schema
Claiming skeleton must be Stopped and emptyno runtime work before identity is fenced
reuse of ensureRequestID / activationID / actionID requires identical payloadidempotency by construction

Analogy: database CHECK constraints for a distributed handshake.

Module 7

The operator

  • Go, built on controller-runtime (the standard operator framework): a shared watch-cache of API objects, a work queue of "reconcile requests", and your Reconcile(ctx, req) function. 1 replica + Lease-based leader election; 64 Mi RAM requested.
  • Watch topology: it watches PreviewEnvironment (only spec-generation / finalizer / deletion changes — status writes don't self-trigger), and watches every child kind (Deployment, StatefulSet, Job, PVC, Service, Secret, NetworkPolicy, HTTPRoute), mapping child events back to the owning CR via labels, since ownerReferences don't exist here.
  • Ignores any CR whose installationID isn't its own → two installations coexist.
Mental model — a persistent render() function: props = the CR spec; the virtual DOM diff = the effect plan; the DOM = the Kubernetes API. It re-renders on every input change, on a timer, on restart — so it must be pure at the decision layer and idempotent at the effect layer.

Reducer / executor split

Inputs
CR + observed children + clock
ReduceLifecycle (pure, no I/O)
validate → admit proposal/actions → ordered effect plan (≤256) + requeue class
Executor
bounded idempotent API calls
Status patch
optimistic lock; abort on UID/generation drift
  • Effect order: namespace → PVCs → NetworkPolicies → Services → workloads (topological dependency order, gated) → clone Jobs → routes → validation Job → prune orphans.
  • Dominance order in the reducer: deletion > writer-fence rejection > Claiming > lease expiry > proposal validation > actions > runtime plan.
  • Requeue classes: event-driven (no timer) · immediate-after-effect (1 s) · deadline (e.g. lease expiry, claim expiry) · rate-limited error backoff · terminal.
  • Rejections are first-class: RequestIDReused · OutcomeExpired · EffectPlanUnbounded · OutcomeCapacityExhausted · WriterFenceRejected · InvalidDeclaration.

What rolls a pod — the revision hash

In the hash (restart-worthy)
  • PodTemplate (image, env, resources…)
  • declared labels/annotations, mounts, resolved source paths
  • configFingerprints — declared config files' content hashes
  • dependencyFingerprints — lockfile hashes (drive Job re-runs)
  • propagated prerequisite revisions (explicit opt-in)
  • action IDs (an explicit dev restart)
  • per-consumer secret fingerprints
Never in the hash (hot-safe)
  • source receipts, commits, content fingerprints
  • changed-path inventories
  • replicas, Service ports (reconciled in place)

⇒ an ordinary code edit changes zero Kubernetes objects. The worktree path is stable; the agent swapped the bytes underneath. That is the entire hot-reload trick.

The operator also computes one runtimeIntentID — a sha256 over the canonical JSON of the whole runtime spec — the sole authority for "did the intent change?" (the client's copy is advisory; golden tests pin Go and Python to byte-identical encoding).

Projected objects

ObjectNotes
Namespacederived name incl. CR-UID hash; created once, never deleted
Deployment / StatefulSetper service; selector {environment-uid, component}; revision stamped as label (12 hex) + full annotation; Stopped ⇒ scaled to 0, never deleted
Jobimmutable, revisioned: name = <svc>-<revision[:12]>; new revision ⇒ new Job, old one pruned; failed Jobs kept as evidence; effect identity injected as env vars
Servicefor non-Job services with ports; ClusterIP preserved on update
PVCretention policy Delete | Retain — retained claims are released (labels stripped), not deleted
HTTPRouteattached to shared Gateway preview-system/preview-fabric#web; hostname from template
NetworkPolicyas declared
Validation Jobstrictly-decoded executor template; ≤3 attempts, 120 s backoff, real failure recovered from the pod termination message

Sources and the state cache enter pods as operator-derived hostPath volumes; raw hostPath in user PodTemplates is rejected outright.

Exact-identity ownership

  • Every managed object must have: empty ownerReferences + exact managed-by / installation / project / environment-name / environment-UID labels + installation / environment-UID annotations + (for namespaced children) the status-recorded namespace UID.
  • Any mismatch — including someone adding an ownerReference — is a foreign resource: reconcile stops, phase Blocked, condition OwnershipConflict, retry every 30 s. The operator repairs nothing it cannot prove.
  • All deletes carry UID + resourceVersion preconditions — the K8s equivalent of an atomic compare-and-delete.
  • Orphan pruning: list by owned labels, delete what the desired set doesn't contain, ≤ 64 per pass, keep failed Jobs referenced in status.
  • On CR deletion a finalizer (a "you may not fully delete until I've cleaned up" marker) deletes proven children in bounded batches — and intentionally retains the namespace: a namespace may contain foreign resources the operator cannot enumerate or prove.

Operator constants worth remembering

ConstantValue
effect retry interval / rollout probe1 s / 2 s
ownership-conflict retry30 s
requeue clamp1 s … 30 min
default retention after lease expiry72 h
max effects per plan / terminal outcomes / child deletes per pass256 / 128 / 64
validation: attempts / backoff / deadline3 / 120 s / 300 s (max 600)
services per env / sources / clones≤ 128 / 32 / 32
RBAC odditiespods: get/list only; secrets: never created (delete for cleanup only); namespaces: no delete verb at all

Every list is bounded, every retry is bounded, every message is truncated (512 B). Nothing in the operator can grow without limit — a deliberate posture.

Module 8

Secrets: client-owned, content-blind

  • Secret values live only on your machine (e.g. .env files, TLS dirs — declared in the project definition, paths overridable per machine).
  • The manager loads them (≤ 1 MiB each / 4 MiB total), and applies them directly as ownerless, environment-labelled K8s Secrets into the env namespace — after verifying the namespace's exact UID and identity metadata.
  • Each Secret gets a random 32-hex revision annotationcontent-independent. Unchanged content (compared only in memory) reuses the revision; changed content gets a fresh unrelated one.
  • Workloads carry a per-consumer fingerprint = sha256 of their (secret-name : revision) list → changing a secret rolls exactly its consumers.
No content-derived hash ever leaves the laptop — not in the CR, not in annotations, not in local state. An observer of the cluster cannot even tell whether a secret's content changed across two Ensures. The operator's RBAC cannot create secrets at all.

Secret flow

local .env / files
values stay here
manager journals opaque revisions
before any external effect
ownerless Secrets applied
into UID-verified namespace
workloads fingerprint revisions
consumers roll on change
  • dev secrets apply ALIAS — the explicit path after editing a secret: in-memory no-op detection; a change journals new revisions and submits one freshly recomputed complete runtime proposal.
  • Replaying an interrupted Ensure reuses its journaled revisions — so replays are invisible, and a different Ensure can't infer content changes.
  • Derivations (≤ 32, bounded set: postgres-password, postgres-dsn, neo4j-auth) compute connection materials from declared inputs — still client-side.
Module 9

Databases: baselines & clones

Protected baseline — a special PreviewEnvironment per project with role Baseline (alias main): built from the canonical main branches, no lease, no watcher, excluded from retention. It is the hot template every feature clones data from.
  • dev main ensure — one routed command: create if absent, exactly resume an interrupted create, activate a Stopped one, then submit a single sync. A merge to main reaches the baseline with no destructive step; untouched inputs classify no-op and write nothing.
  • dev main status — read-only freshness: per component, the commit the baseline was ensured at vs the canonical checkout's HEAD now. Fails closed (unreadable checkout ⇒ "not fresh").
  • Creating a database-backed feature first Ensures the baseline and requires that Ensure to be accepted — but no longer waits for it to be Ready (ADR 0010): the operator gates in-cluster instead.

The clone gate (ADR 0010)

  • A feature's clone declares project.baselineRef → the exact baseline CR (same installation + project).
  • The operator creates no clone/seed Job until it observes the baseline: undeleted, generation-current (has observed its own latest spec), and Ready=True for that same generation. A mid-convergence or stale baseline creates nothing.
  • Each clone is an immutable revisioned Job: clone-<name>-<revision[:12]>. Failure stays visible as evidence; the operator never auto-retries.
  • Recovery = dev clone retry ALIAS CLONE: a target-scoped action naming the exact failed revision; the action ID becomes part of the new Job's revision. Replaying the same action attaches; changed payloads are rejected.
  • While any clone is unfinished, a clone barrier holds back all services outside the clones' prerequisite closure and withholds route programming — you cannot reach a half-cloned environment.

Mode 1: logical clone (default)

baseline postgres
running
pg_dump --format=custom
from baseline namespace
advisory lock
serializes duplicate pods
pg_restore --clean --if-exists
into the feature's running DB
  • Prerequisite: the feature's own database service must be Ready first (declared prerequisite).
  • K8s Jobs are at-least-once → two pods may run the clone concurrently. A PostgreSQL advisory lock (an app-defined DB mutex) makes the restore single-writer.
  • Declared external-effect contract: Idempotent — rerunning yields the same result.

Mode 2: PhysicalSeed

reducer holds target DB at 0 replicas
data dir never opened
seed Job mounts the target's PVC
pg_basebackup from baseline
atomic mkdir(2) marker
records revision + action ID
DB starts with data already in place
  • Inverts the ordering: copy the files of the whole instance before first start — no dump/restore CPU.
  • No running server to lock in ⇒ duplicate pods serialize on one atomic mkdir: same identity + complete stamp ⇒ attach and exit; anything else (different revision, in-progress stamp, populated dir without stamp) ⇒ visible refusal. A seed never wipes a data directory.
  • Honest engineering coda: measured for mission-control and rejected — its logical dump is 8 MiB vs 47–64 MiB PGDATA, below the fixed-cost crossover. The mechanism ships for bigger databases; rabbitmq/neo4j stay cold-path (hostname-coupled state / no safe online clone).

Why all this ceremony?

  • At-least-once is the enemy. Kubernetes may run any Job pod twice; nodes crash mid-restore. Every clone path must be safe under duplication and interruption — hence advisory locks, mkdir markers, immutable revisions, and explicit retries.
  • Failure as evidence. A failed Job is kept, inspectable via dev status / dev logs; silent retries would destroy the trail and can mask data corruption.
  • The baseline is protected. Clones only ever read from it (dump / replication connection); no feature can write baseline state. Refreshing it is an explicit, non-destructive dev main ensure.
  • Umbrella condition DatabaseClonesReady + per-clone status (mode, revision, phase) tell you exactly where things stand.
Module 10

Reaching your environment

  • Cluster side: one shared Traefik behind a Gateway API Gateway (preview-system/preview-fabric, listener web). Each environment publishes HTTPRoute objects with hostname templates like {alias}.mc.preview → hostname-based routing to that env's Services.
  • Laptop side: nothing is exposed to any network. One supervised SSH tunnel:
    
    ssh -N -o ExitOnForwardFailure=yes \
        -L 127.0.0.1:8080:127.0.0.1:30080  user@host
          
    loopback→loopback only, local 8080 → the ingress NodePort 30080.
  • cluster tunnel --background/--status/--stop; health = unit alive and the port actually bound. One tunnel serves all environments (routing is by Host header).
Deliberate decoupling — the tunnel is client-supervised and outside runtime state: an environment can be Ready with the tunnel down, and tunnel failure never touches a PreviewEnvironment.

urls, smoke, forward, shell, logs

  • dev urls ALIAS — prints each route as http://<hostname>:8080/… for use through the tunnel.
  • dev smoke ALIAS — GETs each route's declared smoke checks against 127.0.0.1:8080 with a spoofed Host: header; exit codes distinguish check-mismatch / operational failure / tunnel-unavailable.
  • dev forward ALIAS SVC — for non-HTTP ports (e.g. postgres): one command that runs ssh -L and a remote kubectl port-forward chained; lives while the process lives.
  • dev shell ALIASkubectl exec -it into the tier's dedicated dev-shell workload (a container with your source mounted — where an in-env agent can work and commit; cf. dev remote publish).
  • dev logs ALIAS SVC — label-selected pod logs, remote kubectl.

Application checks

  • Beyond "pods are Ready", the project can declare application checks: HTTP-JSON probes (and Job checks) run inside the cluster by one operator-created validation Job after convergence.
  • Policy Optional | Required; correlation RuntimeOnly | ExactSource (the check proves it saw this exact source receipt, or reports BehaviorPassedSourceUnproven).
  • Bounded like everything else: ≤ 32 checks, ≤ 3 attempts, 120 s backoff, 300 s deadline; on timeout the real failure is recovered from the pod's termination message.
  • Result lands in the Validated condition — the strongest "your env truly works" signal, consumed by dev diagnose --gate validated.
Module 11

Create, end to end

dev session my-feature → manager plans; for DB projects, first Ensures the baseline (accepted, not Ready).
Two-plane claim: Claiming-skeleton CR (nonce, fence, 15-min expiry) + source-agent writer claim. Operator echoes WriterPending; creates nothing.
Ingest per component: incremental bundle + overlay → validated worktree at …/ENV_UID/component → receipts + semantic fingerprints.
Manager observes the operator-created namespace UID, applies Secrets with fresh opaque revisions.
Claiming → Active flip with the complete spec (services, PVCs, routes, clones, proposal IDs, receipt vector).
Operator: validate → accept proposal → effect plan → namespace, PVCs, policies, Services, workloads in dependency order (clone-gated), clone Jobs after baseline Ready, routes after barrier, validation Job.
Phase Ready; a RuntimeActivation terminal outcome lands in the ledger; the manager's wait resolves against exactly that receipt.

Down, leases, retention, GC

  • dev downdesiredState: Stopped: workloads scale to 0, Jobs/routes go away, PVCs and Secrets staydev up is cheap.
  • Leases: features carry an absolute lease.expiresAt (renewal computes a business-day deadline in your current timezone, then stores the absolute instant — changing timezone later can't move it). Baselines, pinned envs, and no-expiry envs are protected.
  • Expiry is reconciled in-cluster — works with every client offline:
    1. at expiry: stop workloads, condition LeaseExpired
    2. deleteAfter = expiresAt + retention (default 72 h)
    3. after that: delete only exact-identity objects, with UID/RV preconditions → phase GarbageCollected
  • The namespace survives GC (unprovable contents); dev gc is a read-only view — there is no client-side cleanup daemon.

Purge: destruction with receipts

Stop the local watcher.
One source-agent purge: exact env UID + fence + complete receipt vector + request ID + dedupeUntil. Agent validates all receipts, writes a durable purge tombstone, removes source, and retires the UID forever.
Only after the tombstone: delete the CR with its recorded UID precondition.
The finalizer deletes proven runtime children; namespace remains.
Clean the local binding (keeping an expiry tombstone); stop the alias manager.
  • A lost response is resolved via purge-status; a same-name successor has a different UID and can never satisfy the old tombstone. A stale manager can never recreate source for a retired UID.
  • Broad "rm -rf the source root" does not exist by design — the routed purge is the only deletion path, and it refuses rather than widening its target.

The no-adoption principle

  • Nothing in the system ever "takes over" an object it didn't create with proof: not a same-name CR, not an expired skeleton, not a half-deleted namespace, not a foreign Secret, not a warm pod.
  • Instead: fresh UID → fresh namespace → fresh objects; the old generation is fenced, retained, or explicitly purged.
  • What this buys, concretely:
    • a crashed client can always resume or abandon — never corrupt;
    • two devs colliding on a name get a visible Blocked, not silent mixing;
    • deletion can never cascade into someone else's resources;
    • every mutation is attributable to exactly one fenced writer.
  • The cost — rebuilding state from scratch each time — is then bought back with caches, not shared identity. Which is Module 12.
Module 12

Making creation fast

Measured cost order for a cold create (descending):

cold baseline
33+ services boot
dependency Jobs
2× npm ci, pip install
logical DB clones
dump + restore ×2
stateful boots
neo4j, rabbitmq
source transfer
full bundle
Strategy: warm state, cold identity. Every environment keeps a fresh UID, namespace, and full exact-identity treatment; speed comes from hydrating its initial state — dependency volumes, database contents, git objects — from the hot baseline, instead of rebuilding from nothing.

The obvious alternative — a pool of pre-warmed claimable environments — was explicitly rejected: claiming is adoption with a protocol; the alias is baked into immutable names; secret revisions would roll every warm pod anyway; idle spares burn RAM on one shared host.

The state cache (ADR 0009)


/var/lib/preview-fabric/state-cache/
└── INSTALLATION/PROJECT/deps/<DEP_FINGERPRINT>/<VOLUME>/
    ├── .preview-fabric-complete     # marker written before publish rename
    └── node_modules/ …              # finished dependency tree
    
  • Content-addressed by the dependency fingerprint (lockfile hash) that already names the Job's revision. States: absent · staging (.staging.* sibling) · published. No partial published state (marker exists before the atomic rename(2)); concurrent publishers resolve via kernel ENOTEMPTY.
  • The baseline's dependency Job publishes; a feature's Job imports (only into an empty volume, marker-verified) — a feature whose lockfiles match the baseline runs no package manager at all.
  • cp --reflink=auto: metadata-cost copy-on-write on capable filesystems (XFS/btrfs), silently a plain copy on ext4. An accelerator, never a dependency.
  • Nobody reconciles this space: no K8s objects, no owner, no finalizers. The operator only derives the mount path; the Jobs the project declares do all copying. Any anomaly ⇒ fall through to the ordinary cold build. Wrong is impossible; slow is the worst case.

The rest of the toolkit

ChangeMechanism
uv replaces piplock generation + installs; 26 s vs 68 s measured. Invisible to app teams (requirements.lock is a fabric artifact)
volume co-locationinstall target + package cache on one volume — hardlink/reflink across PVCs fails with EXDEV (cross-device); co-location makes them cheap again
incremental dependency reconcileJobs converge instead of wipe: stamp match ⇒ skip · populated + mismatch ⇒ npm install + prune / convergent uv · empty ⇒ npm ci — keyed by a digest stamp file on the volume
incremental git bundles(Module 5) first push of a new env is delta-only vs advertised tips — this killed the need for a server-side "worktree fork" op
hot baselinedev main ensure keeps the template warm; refresh not charged to feature creation
PhysicalSeedships, but data-driven off for mission-control (dump too small to matter)

Results (live, office-2 host)

  • k3s on ext4/NVMe (~3.1 GB/s) — so all cache copies are full copies; reflink/XFS is an untapped follow-up.
  • Baseline refresh through an additive definition rollout: 70 s.
  • Full-tier mission-control + workspace feature create: Ready in 1 m 50 s — all three dependency volumes imported from cache (zero package-manager work), logical clones green, smoke 8/8.
  • Every fast path is gated on a fingerprint or emptiness check with a cold fallback — a stale cache can only produce a slower environment, never a wrong one.

Also fixed along the way: the additive-rollout defect (a service added to the project definition previously couldn't converge onto a live environment through any non-destructive operation), and stale receiptless syncs now terminalize as PLAN_STALE.

Module 13

How it's tested

LayerWhat
Golden vectors (cross-language)Python must reproduce Go's canonical JSON byte-for-byte for runtimeIntentID, namespace names, claiming shapes — paired Go+Python tests over shared fixtures; a golden diff is a contract change needing individual justification
Python (~40 files)real git repos in tmpdirs; watcher on a real filesystem; wire-protocol framing; journal recovery; docs-drift tests
Gorace-enabled unit tests; envtest (a real kube-apiserver + etcd, no nodes) for reconcile logic; differential fuzz of defaulting/pruning; a 100 k-outcome volume test
e2e (kind)two suites in CI, everything digest-pinned
kind — "Kubernetes in Docker": a full throwaway cluster inside containers, used in CI. envtest — just the API server, for fast controller tests.

The client-path e2e trick

  • Problem: the client's only transport is SSH — how do you e2e-test it against a container cluster?
  • Answer: put the whole trust domain in a box. Dockerfile.e2e-sshd builds an Alpine sidecar running sshd (per-build host keys, pubkey-only) with kubectl + a rewritten kubeconfig inside; cluster configure points at it. The client can't tell it from a real host.
  • All client state is sandboxed (HOME, config/state roots, process supervisor); phases P0–P8 cover transport, bootstrap, create, fact assertions, hot path returns without pod replacement, watcher-observed edit sync, receipt-CAS purge, supervision teardown — with wall-clock timings printed per phase.
  • Assertions mirror a recorded live-host run, so CI and reality can't quietly drift apart.

Installing & operating

  • make verify && make build && make images → push both images → record digests → ./scripts/operator-install.sh with digest-pinned image env vars (…@sha256:… enforced; tags refused).
  • Install preflight checks the Gateway API version and that the shared Gateway is Accepted+Programmed with an HTTP listener named web; scripts refuse fixed-name resources that exist without exact installation labels; reset/uninstall demand exact confirmation + precondition deletions and never touch foreign resources.
  • What lands: namespace preview-fabric-system, the CRD (with a schema-floor annotation the client checks), RBAC, operator Deployment, source-agent Deployment. Uninstall retains the CRD, namespace, and any pull Secret.
  • Admin without a laptop kubeconfig: the ops scripts can run through preview-local cluster _kubectl — the client's own SSH bridge.
  • Day-2 truth lives in docs/operations/operator-runbook.md: status interpretation, source/database recovery, limitations.

The invariants, one last time

  1. Never adopt. Ownerless exact identity; empty ownerReferences; UID-pinned everything.
  2. Plan → Ensure once → Wait → Diagnose. Identities journaled before side effects; receipts compare-and-swapped; exact replay.
  3. The operator is level-triggered and bounded — bounded API work, bounded lists, bounded retries, no blocking waits, validation-only knowledge of host roots.
  4. Secret values and content hashes never leave the laptop — opaque revisions only.
  5. Jobs are immutable revisions; failure is evidence; retry is explicit.
  6. Golden vectors pin cross-language contracts.
  7. Every fast path has a gate and a cold fallback — stale caches make slower environments, never wrong ones.

Further reading, in order: docs/architecture/overview.mdruntime-lifecycle.mdsource-authority.mddocs/state-spaces.md → ADRs 0006–0010 → the operator runbook.