Draw the Three Planes and Split the Microservices
Build and break one support application locally so service boundaries become observable facts, not boxes in a diagram.
System map · Day 03
Whole-system design
Five stable layers. Today's work is expanded and linked; the rest stays in context.
Product and authority
Covered — Generated application planeAhead — People and product entry points · Identity and policy
HelixWorks control plane
Source-backed today
Authorizes the support project and coordinates generation and preview without absorbing their responsibilities.
Delivery and desired state
Ahead — Git desired state · CI and immutable artifacts · Argo CD reconciliation
Cloud and orchestration
Covered — Terraform and AWS APIs · Kubernetes or EKS control planeAhead — Accounts, VPC, DNS, and private paths
Compute and traffic
Covered — Worker computeAhead — Generated app workloads · Ambient mesh data plane
Platform service workloads
Source-backed today
Runs generator, runtime, broker, and evidence as independently observable service processes.
Storage and evidence
Ahead — Infrastructure state · Cluster desired and live state
Product data and artifacts
Source-backed today
Separates control records, generated artifacts, preview payloads, and evidence into four durable volumes.
Evidence and observability
Source-backed today
Follows the transactional outbox through broker delivery into independently stored evidence and recovery proof.
The enterprise problem and today’s slice
Enterprise problem: If one service creates applications, generates code, hosts previews, and authorizes every eventual app user, one bug or leaked credential can cross the whole product. Teams also cannot tell which service may change which data.
Whole-course context: Day 01 bounded HelixWorks as an application-building platform, and Day 02 supplied a local Kubernetes substrate. Today steps back to a smaller Docker Compose lab so you can see the product boundaries before Kubernetes adds more moving parts.
Today’s slice: You will send one support-application request through the provider control plane and hosted runtime, then inspect its independently progressing audit path. You will also mark the generated-application plane honestly: this revision generates HTML, but it does not implement the generated app's own end-user authentication or domain database.
End-of-day evidence: Successful create, generate, and preview responses; two authorization denials; and a stopped-broker/stopped-subscriber recovery trace tied to revision ae970246d39778a1d50354136cbab869223da109.
Still unsolved: A browser-accessible preview URL, generated-app users and roles, support-ticket data, enterprise connectors, publication, and Kubernetes delivery remain later slices.
The governing rule is simple: split a boundary when authority or authoritative state changes, not merely when the code uses a different verb. By the end, you will be able to predict which component must accept, reject, persist, or recover each step.
Before you start: make the lab reproducible
An architecture lesson is useless if a beginner cannot reproduce its evidence. Pinning the source revision makes every excerpt and expected response below refer to the same code, while a Compose project name gives this lab its own container, network, and volume names.
You need Git, Docker with the Compose plugin, Python 3, and curl. Ports 8080 and 8088 must be free. The lab creates no cloud resources.
git clone https://github.com/ZGTR/helixworks-kubernetes-lab.git helixworks-day03
cd helixworks-day03
git checkout --detach ae970246d39778a1d50354136cbab869223da109
export LAB_PROJECT=helixworks-day03
test "$(git rev-parse HEAD)" = "ae970246d39778a1d50354136cbab869223da109"
docker compose -p "$LAB_PROJECT" config --quiet
The detached checkout prevents a later branch update from silently changing the exercise. LAB_PROJECT is a shell variable used by every Compose command below; if you open a new terminal, export it again before continuing.
Start with the smallest useful three-plane model
Calling every component a “microservice” hides the real risk: a service can be small yet still hold authority that belongs somewhere else. A plane is a boundary with its own actors, policy, and authoritative state—the state that wins when copies disagree.
- The provider control plane is the part of HelixWorks that accepts the customer's intent. It owns the Acme organization reference, project, blueprint, generated artifact, collaborators, and provider audit trail.
- The hosted runtime is where HelixWorks records a preview or published deployment. It owns the runtime copy and its lifecycle, not the customer's project policy.
- The generated-application plane is the application HelixWorks produced. In a complete support app, this plane would own support agents, sessions, tickets, and rules such as “an agent may read tickets assigned to their queue.”
The third plane is not another name for the runtime. The runtime hosts an artifact; the generated application decides what its own users may do with app-owned data. This pinned lab reaches the runtime with generated HTML, but the HTML is only a form and button. There is no support user, session, ticket store, app role, or app-data authorization check yet.
Decide a boundary before naming a service
Splitting on nouns such as “generator” or “runtime” can still produce the wrong design, because names do not prove ownership. For any new responsibility, ask the following questions in order; a changed answer is evidence that you crossed a boundary.
| Question | Support-app example | Boundary decision |
|---|---|---|
| Who is the actor? | Acme's platform owner creates a project; a future support agent answers a ticket | Provider actor and app end user belong to different planes |
| Which credential is accepted? | A provider bearer token enters the control plane; a service token enters generator/runtime | Public identity and service identity are separate checks |
| Which state is authoritative? | Project blueprint versus runtime deployment versus future ticket | Different owners require separate stores and APIs |
| Who may revoke access? | Project owner revokes a collaborator; future support admin revokes an agent | One plane's membership must not grant another plane automatically |
| What fails or scales independently? | Generation can be CPU-heavy; preview deployment can fail; evidence can lag | Separate processes contain failure and scale by responsibility |
Use this decision rule in unfamiliar systems: keep operations together while actor, policy, state owner, failure mode, and scaling pressure stay the same; introduce an interface when one of them changes materially. A network hop has a cost, so “one verb per service” is not a goal.
Read the source as a responsibility map
A diagram can claim clean boundaries while the code quietly bypasses them. The pinned source tree exposes one public provider entry point, two synchronous capability services, and an asynchronous evidence path:
In services/control_plane/domain.py, the provider model depends on small ports—interfaces describing a required capability—rather than importing HTTP or database details:
class Generator(Protocol):
def generate(self, blueprint: dict[str, object]) -> dict[str, str]: ...
class Runtime(Protocol):
def deploy(self, organization_id: str, app_id: str,
payload: dict[str, object]) -> dict[str, object]: ...
This is dependency inversion: the policy code names what it needs, and the outer composition code supplies how to reach it. services/control_plane/app.py supplies HTTP clients for the two ports:
class GeneratorClient:
def generate(self, blueprint):
return request_json("POST", f"{self.url}/generate", blueprint)
class RuntimeClient:
def deploy(self, organization_id, app_id, payload):
return request_json("PUT", f"{self.url}/tenants/{organization_id}/apps/{app_id}", payload)
The separation has a concrete effect. services/generator/app.py deterministically turns the workflow blueprint into HTML and a content hash. services/runtime/app.py stores that artifact as a tenant-and-app-scoped preview payload. Neither service decides whether the requesting person is an Acme collaborator; Forge._authorize makes that provider decision before either port is called.
Start the services and prove each responsibility is alive
Starting containers does not prove the application path works, and the static web container is not the generated preview. First prove all six processes exist, then probe the public control-plane endpoint and the four internal service endpoints separately.
compose.yaml wires the control plane to generator, runtime, broker, and evidence by Compose service name:
control-plane:
environment:
GENERATOR_URL: "http://generator:8080"
RUNTIME_URL: "http://runtime:8080"
EVIDENCE_URL: "http://evidence:8080"
BROKER_TOPIC: "http://broker:8080"
volumes: ["control-data:/data"]
ports: ["8080:8080"]
Build and start the isolated lab:
docker compose -p "$LAB_PROJECT" up --build -d
docker compose -p "$LAB_PROJECT" ps --services --status running
Expected observation: broker, control-plane, evidence, generator, runtime, and web are listed. Order may differ.
Probe the two host-visible endpoints:
curl --fail --silent --show-error http://localhost:8080/healthz
curl --fail --silent --show-error http://localhost:8088/ >/dev/null
The first command returns {"service": "control-plane"}; the second exits successfully. Now ask the control-plane container to probe the internal-only services on their Compose network:
docker compose -p "$LAB_PROJECT" exec -T control-plane python - <<'PY'
import json
import urllib.request
for service in ("broker", "evidence", "generator", "runtime"):
with urllib.request.urlopen(f"http://{service}:8080/healthz", timeout=5) as response:
print(service, response.status, json.load(response))
PY
Expected observation: four lines with status 200, each naming the service probed. This proves reachability; it does not yet prove that ownership rules or persistence work.
Trace one support app from create to generate to preview
A component list says nothing about how a customer's request crosses boundaries. This trace keeps one scenario—Acme's “Support operations” workflow—and checks the output of every hop before moving on.
Mint a short-lived bearer token, a signed credential representing the provider actor owner@acme.test in organization acme:
TOKEN="$(python3 scripts/mint-local-token.py)"
AUTH=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
Create the provider project:
curl --fail --silent --show-error "${AUTH[@]}" \
--data '{"project_id":"support","organization_id":"acme","name":"Support operations","archetype":"workflow"}' \
http://localhost:8080/projects | python3 -m json.tool
Expected observation: HTTP success with project_id: "support", owner: "owner@acme.test", status: "draft", a workflow blueprint, and no artifacts. ForgeController.command first rejects a body whose organization differs from the signed token, then Forge.create stores the project and adds its owner as the first collaborator.
Generate an immutable artifact:
curl --fail --silent --show-error "${AUTH[@]}" \
--data '{}' http://localhost:8080/projects/support/generate | python3 -m json.tool
Expected observation: an artifact_id beginning with sha256:. The provider authorizes the collaborator, sends only the blueprint to generator, stores the returned HTML under the organization and content hash, then adds the artifact reference to the project. The generated HTML is <main><h1>Support operations</h1><form><button>Submit request</button></form></main>; it contains presentation, not support-ticket behavior or end-user authorization.
Deploy that artifact as a preview record:
curl --fail --silent --show-error "${AUTH[@]}" \
--data '{}' http://localhost:8080/projects/support/preview | python3 -m json.tool
Expected observation: a JSON object containing organization_id: "acme", app_id: "support", mode: "preview", the same artifact_id, and the generated source. The runtime has persisted a hosted deployment payload. This revision does not return a browser preview URL, so the honest milestone is “runtime accepted and stored the preview,” not “an end user used the app.”
The synchronous path is now visible:
Break the evidence path, then recover each stage
An audit subscriber that must be online during every customer request would turn an evidence outage into a product outage. This experiment stops both transport and subscriber, proves the control plane durably records pending events, then restarts one stage at a time.
An outbox is a database table written by the business service before a background relay publishes its rows. In services/control_plane/outbox.py, publish inserts a content-hashed message, while relay marks it published only after the broker accepts it. services/evidence/app.py acknowledges a broker delivery only after its own store accepts the event.
Stop broker and evidence, then create another support workflow and carry it through preview. Generator and runtime remain available.
docker compose -p "$LAB_PROJECT" stop broker evidence
TOKEN="$(python3 scripts/mint-local-token.py)"
AUTH=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
curl --fail --silent --show-error "${AUTH[@]}" \
--data '{"project_id":"support-recovery","organization_id":"acme","name":"Support recovery","archetype":"workflow"}' \
http://localhost:8080/projects >/dev/null
curl --fail --silent --show-error "${AUTH[@]}" \
--data '{}' http://localhost:8080/projects/support-recovery/generate >/dev/null
curl --fail --silent --show-error "${AUTH[@]}" \
--data '{}' http://localhost:8080/projects/support-recovery/preview >/dev/null
sleep 3
All three HTTP commands exit successfully even though audit transport is down. Inspect only this project's pending outbox rows:
docker compose -p "$LAB_PROJECT" exec -T control-plane python - <<'PY'
import sqlite3
rows = sqlite3.connect("/data/control.db").execute(
"SELECT published_at, attempts, last_error FROM event_outbox "
"WHERE payload LIKE '%support-recovery%' ORDER BY rowid"
).fetchall()
print(rows)
PY
Expected observation: three rows, one each for project.created, artifact.generated, and preview.deployed. Their published_at values are None; retry counts are at least one and last_error describes the unavailable broker. The provider request and its audit intent survived in the same control-plane volume.
Restart only the broker and wait for the outbox relay:
docker compose -p "$LAB_PROJECT" start broker
sleep 3
docker compose -p "$LAB_PROJECT" exec -T control-plane python - <<'PY'
import sqlite3
row = sqlite3.connect("/data/control.db").execute(
"SELECT COUNT(*), SUM(published_at IS NOT NULL) FROM event_outbox "
"WHERE payload LIKE '%support-recovery%'"
).fetchone()
print(row)
PY
Expected observation: (3, 3). The broker accepted all three messages although evidence is still stopped. Confirm that its evidence subscription has three unacknowledged deliveries:
docker compose -p "$LAB_PROJECT" exec -T broker python - <<'PY'
import sqlite3
row = sqlite3.connect("/data/broker.db").execute(
"SELECT COUNT(*) FROM topic_deliveries AS d "
"JOIN topic_messages AS m ON m.message_id=d.message_id "
"WHERE d.subscriber='evidence' AND d.acknowledged_at IS NULL "
"AND m.payload LIKE '%support-recovery%'"
).fetchone()
print(row)
PY
Expected observation: (3,). Finally restart evidence and wait until it owns all three events:
docker compose -p "$LAB_PROJECT" start evidence
for attempt in $(seq 1 15); do
RECOVERED="$(curl --fail --silent --show-error -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/evidence | python3 -c \
'import json,sys; print(sum(e["project_id"] == "support-recovery" for e in json.load(sys.stdin)["events"]))')"
[ "$RECOVERED" = 3 ] && break
sleep 1
done
printf 'support-recovery evidence events=%s\n' "$RECOVERED"
Expected observation: support-recovery evidence events=3. The stages progressed independently: provider transaction to outbox, outbox to broker, and broker to evidence store. A duplicate delivery is safe because the evidence ID is content-derived and unique.
Assign every durable record to one owner
Recovery becomes guesswork when two services both believe their copy is authoritative. The local lab uses four Compose volumes and five logical tables; the table below says who may change each record and what another plane is allowed to keep.
| Record | Local store | Owning boundary | Why it owns the record | Cross-boundary rule |
|---|---|---|---|---|
| Project, blueprint, collaborators, artifact references | projects in /data/control.db on control-data | Provider control plane | This is customer intent and provider access policy | Runtime receives only the deployment payload it needs |
| Pending audit message | event_outbox in /data/control.db on control-data | Provider control plane | The provider transaction must not forget its audit intent | Broker receives an immutable message ID and payload |
| Generated HTML artifact | artifacts in /data/artifacts.db on control-data | Provider artifact adapter | Artifact identity is content-addressed and tenant-scoped | Runtime receives source for the selected artifact |
| Preview deployment | runtime_apps in /data/runtime.db on runtime-data | Hosted runtime | Runtime owns effective hosted deployment state | It does not grant provider collaborators or app users |
| Message and subscriber delivery | topic_messages and topic_deliveries on broker-data | Broker | Each subscriber advances independently | Acknowledgement does not alter the project or runtime |
| Provider audit event | evidence_events on evidence-data | Evidence service | Support and audit queries need durable, tenant-scoped history | Evidence cannot mutate project or runtime state |
| Support user, session, ticket, queue role | No table in this revision | Future generated-application plane | App-domain authorization belongs to the generated app | Must not be inferred from provider or runtime credentials |
The same service may own several cohesive records, as the control plane does here. Separate storage is justified when another authority, lifecycle, failure domain, or scaling pattern appears—not to satisfy a diagram.
State exactly what the lab proves
An end-to-end command can still support a claim larger than the implementation. Keep the conclusion bounded to observations made in this lesson.
This lab proves:
- a signed provider actor can create one tenant-scoped support project;
- only a project collaborator can request generation and preview;
- only a service credential can call generator directly;
- generator returns deterministic, content-addressed HTML;
- runtime durably records the tenant/app preview payload;
- control-plane work can finish while broker and evidence are unavailable;
- outbox, broker delivery, and evidence storage catch up in order after recovery.
This lab does not prove:
- that the generated support app has end users, login, sessions, roles, tickets, or ticket authorization;
- that
webon port8088serves the generated preview; - that a preview has a public URL, TLS, isolation from another runtime, or production availability;
- that Docker Compose provides Kubernetes, Argo CD, multi-machine resilience, or cloud durability.
That distinction is the three-plane model doing useful work: it tells you which missing proof belongs to which future boundary instead of calling the whole platform “done.”
Final proof checklist and cleanup
A lesson is complete only when another person can falsify its claims. Check the observed result beside each command, then remove only the isolated Compose project and its four lab volumes.
- [ ]
git rev-parse HEADprintsae970246d39778a1d50354136cbab869223da109. - [ ] Compose lists six running services before the failure experiment.
- [ ] Create returns the Acme-owned
supportproject. - [ ] Generate returns a
sha256:artifact ID. - [ ] Preview returns
mode: "preview"and the same artifact ID. - [ ] A valid same-tenant non-collaborator receives
401before generation. - [ ] Generator rejects a direct call without the internal service token.
- [ ] Three
support-recoveryoutbox rows remain pending while broker is stopped. - [ ] The broker holds three unacknowledged evidence deliveries while evidence is stopped.
- [ ] Evidence reaches three events after its process restarts.
- [ ] You can name the owning plane for project, runtime deployment, audit event, and future support ticket.
Cleanup is intentionally scoped by the same project name used at startup:
docker compose -p "$LAB_PROJECT" down --volumes
docker compose -p "$LAB_PROJECT" ps --all
The first command removes this lab's containers, network, and four volumes. It does not prune unrelated Docker resources or delete the cloned source directory. The second command should show no resources for helixworks-day03.