A concrete mock-up of an ALB deploy through the Vivarta substrate, end-to-end. One CLI call, one config file, one CFN template, three boundaries, and the table of every crossing the run produces.
The ALB is intentionally trivial: two listeners, default 404 fixed-response. The point isn't the deliverable. It's seeing every address, type, and payload the substrate emits, so we can audit the model before any boundary code lands.
One CLI call. The yaml file is the deploy specification. Everything else falls out of running it.
wanderland --type cli config.yml deploy
The engine boots from config.yml, dispatches to the /deploy route, walks its chain (start_run → create_stack → publish_stack), and exits with the run's exit-crossing summary on stdout. Exit code is zero on success, non-zero on failure.
For replay-on-failure, the same call with VIVARTA_RUN_ID set picks up where the previous run left off — covered in section 5.
A future evolution will split config.yml into two files: an engine config (stable across deploys) and a deployment config that varies per deploy. The CLI will bind them via a flag like --deployment-config <yaml> — the deployment yaml acts as the request body, and the same engine config drives many different deploys. For tonight, both halves live in one file.
One file. Defines engine setup (boundary_path, storage, adapters, resolvers), identity values (system, env, sub_env), CFN inputs (template, parameters, tags), and the route that wires the chain.
service: proquest-alb-mvp
boundary_path: lib/proquest_alb/boundaries
resolvers:
Fixture:
fixture_dirs: [spec/fixtures]
storage:
mounts:
":advaita:":
driver: sqlite
path: !Env { name: ADVAITA_DB, default: /var/wanderland/advaita.db }
adapters:
cfn:
kind: aws
service: cloudformation
mode: !Env { name: WANDERLAND_AWS_MODE, default: live }
region: us-east-1
# Identity in the substrate tree
system: proquest-alb
environment: dev
sub_environment: main
# CFN inputs
template_path: templates/alb-with-404.yml
parameters:
ALBScheme: internal
AvailabilityZoneA: us-east-1a
AvailabilityZoneB: us-east-1c
AvailabilityZoneC: us-east-1f
IdleTimeout: "300"
VpcId: vpc-0abc123def456789
SubnetA: subnet-0aaa111
SubnetB: subnet-0bbb222
SubnetC: subnet-0ccc333
CertificateArn: arn:aws:acm:us-east-1:123456789012:certificate/abcd-1234
tags:
business_unit: proquest
namespace: pqp
subsystem: alb
component: edge
environment: dev
sub_environment: main
version: 0.0.1-mvp
routes:
/deploy:
method: post
name: deploy
chain:
- boundary: start_run
- boundary: create_stack
args:
adapter: cfn
- boundary: publish_stack
args:
adapter: cfn
Anatomy:
service, boundary_path, resolvers, storage, adapters are wanderland-core engine setup. Same shape as any other site.system, environment, sub_environment, template_path, parameters, tags are the deploy values. The boundaries read these directly from runtime.config.domain at request time. Different deploys use different config files; same chain consumes them.start_run opens the run record and stamps a ULID. create_stack provisions the CFN stack. publish_stack captures outputs and resources as crossings under the run-tree address and marks the run active.templates/alb-with-404.ymlInternal ALB, two listeners (HTTP → HTTPS redirect, HTTPS → fixed 404). Three subnets across AZs.
AWSTemplateFormatVersion: "2010-09-09"
Description: Advaita ALB MVP — two listeners, default 404 fixed-response, nothing to see here.
Parameters:
ALBScheme: { Type: String, AllowedValues: [internal, internet-facing] }
AvailabilityZoneA: { Type: AWS::EC2::AvailabilityZone::Name }
AvailabilityZoneB: { Type: AWS::EC2::AvailabilityZone::Name }
AvailabilityZoneC: { Type: AWS::EC2::AvailabilityZone::Name }
IdleTimeout: { Type: Number, Default: 300 }
VpcId: { Type: AWS::EC2::VPC::Id }
SubnetA: { Type: AWS::EC2::Subnet::Id }
SubnetB: { Type: AWS::EC2::Subnet::Id }
SubnetC: { Type: AWS::EC2::Subnet::Id }
CertificateArn: { Type: String }
Resources:
ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Advaita ALB MVP — ports 80/443 inbound
VpcId: !Ref VpcId
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0 }
- { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 }
LoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: !Ref ALBScheme
Type: application
SecurityGroups: [!Ref ALBSecurityGroup]
Subnets: [!Ref SubnetA, !Ref SubnetB, !Ref SubnetC]
LoadBalancerAttributes:
- { Key: idle_timeout.timeout_seconds, Value: !Ref IdleTimeout }
ListenerHTTP:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref LoadBalancer
Port: 80
Protocol: HTTP
DefaultActions:
- Type: redirect
RedirectConfig: { Protocol: HTTPS, Port: "443", StatusCode: HTTP_301 }
ListenerHTTPS:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref LoadBalancer
Port: 443
Protocol: HTTPS
Certificates: [{ CertificateArn: !Ref CertificateArn }]
DefaultActions:
- Type: fixed-response
FixedResponseConfig:
StatusCode: "404"
ContentType: text/plain
MessageBody: "nothing to see here, move along"
Outputs:
LoadBalancerArn: { Value: !Ref LoadBalancer }
LoadBalancerDNSName: { Value: !GetAtt LoadBalancer.DNSName }
LoadBalancerHostedZoneID: { Value: !GetAtt LoadBalancer.CanonicalHostedZoneID }
ListenerHTTPArn: { Value: !Ref ListenerHTTP }
ListenerHTTPSArn: { Value: !Ref ListenerHTTPS }
SecurityGroupId: { Value: !Ref ALBSecurityGroup }
Each boundary is a small class that reads the deploy values from runtime.config.domain and writes crossings under the run-tree address. They live in the wanderland-pipelines library when that gem is broken out (or in lib/proquest_alb/boundaries/ for now).
start_runOpens the run record. Generates a ULID for the run-id (or reads VIVARTA_RUN_ID from env for replay), computes the run-tree address (:advaita:<system>:<env>:<sub_env>:runs:<run_id>), and writes metadata, tags, and an initial lifecycle:new crossing under it. Returns the run-id and run-tree address in the result so downstream boundaries know where to write.
create_stackReads template path, parameters, and tags from runtime.config.domain. Pulls the configured CFN adapter via input.adapter(:cfn). Auto-detects capabilities from the template (CAPABILITY_IAM, CAPABILITY_NAMED_IAM if the template creates IAM resources). Generates a deterministic stack name from tags. Calls the CFN change-set probe to inspect the diff, then execute_change_set (or the direct create_stack path for a fresh stack). Polls describe_stacks until completion. Writes provision crossings under the run-tree address for each provider event.
The boundary checks for its own completion crossing before doing work. If create-complete is already there from a prior run with the same run-id, it skips and returns immediately. The replay model in section 5 covers the contract.
publish_stackReads the stack identifier from prior crossings (the create-complete written by create_stack). Calls describe_stacks for outputs and describe_stack_resources for the resource list. Writes each CFN output as a crossing at <run_addr>:outputs:<key>. Writes each resource as a crossing at <run_addr>:resources:<logical_id>. Writes a lifecycle:active crossing and an active-pointer crossing at <slot_addr>:active so the deploy is externally identifiable. Closes with a run:complete crossing to mark the run done.
In the old peregrine model, what publish_stack does here was split across nimbus publish_resources, nimbus publish (deployment record), nimbus activate, and nimbus complete_deployment. Folded into one boundary because all four happen at the same logical moment: the deploy succeeded, this run is now the active one in its slot.
Every crossing a successful run produces. Run-id mocked as 01HZJ9X8K5YQR4T6V8B2WFGM3Q.
Address abbreviations:
R := :advaita:proquest-alb:dev:main:runs:01HZJ9X8K5YQR4T6V8B2WFGM3QS := :advaita:proquest-alb:dev:mainB := :boundaries| # | to_addr |
from_addr |
type_addr |
Payload |
|---|---|---|---|---|
| 1 | R:metadata |
B:start_run |
:types:run:metadata |
{run_id, system, environment, sub_environment, started_at} |
| 2 | R:tags |
B:start_run |
:types:run:tags |
full tag bundle from config |
| 3 | R:lifecycle:new |
B:start_run |
:types:lifecycle:new |
{at, reason: "run_started"} |
| 4 | R:provision:requested |
B:create_stack |
:types:cfn:create-requested |
{stack_name, template_ref, parameters, tags, capabilities} (capabilities auto-detected) |
| 5 | R:provision:change-set:created |
B:create_stack |
:types:cfn:change-set-created |
{change_set_id, summary, changes_count} |
| 6 | R:provision:change-set:executed |
B:create_stack |
:types:cfn:change-set-executed |
{change_set_id, executed_at} |
| 7 | R:provision:waiting |
B:create_stack |
:types:cfn:wait |
{state: "CREATE_IN_PROGRESS", elapsed_s} (one or more, periodic) |
| 8 | R:provision:complete |
B:create_stack |
:types:cfn:create-complete |
{stack_id, stack_name, status: "CREATE_COMPLETE", duration_s} |
| 9 | R:outputs:LoadBalancerArn |
B:publish_stack |
:types:cfn:output |
{key, value} |
| 10 | R:outputs:LoadBalancerDNSName |
B:publish_stack |
:types:cfn:output |
{key, value} |
| 11 | R:outputs:LoadBalancerHostedZoneID |
B:publish_stack |
:types:cfn:output |
{key, value} |
| 12 | R:outputs:ListenerHTTPArn |
B:publish_stack |
:types:cfn:output |
{key, value} |
| 13 | R:outputs:ListenerHTTPSArn |
B:publish_stack |
:types:cfn:output |
{key, value} |
| 14 | R:outputs:SecurityGroupId |
B:publish_stack |
:types:cfn:output |
{key, value} |
| 15 | R:resources:LoadBalancer |
B:publish_stack |
:types:cfn:resource |
{logical_id, physical_id, resource_type: "AWS::ElasticLoadBalancingV2::LoadBalancer"} |
| 16 | R:resources:ListenerHTTP |
B:publish_stack |
:types:cfn:resource |
(same shape, per resource) |
| 17 | R:resources:ListenerHTTPS |
B:publish_stack |
:types:cfn:resource |
(same shape) |
| 18 | R:resources:ALBSecurityGroup |
B:publish_stack |
:types:cfn:resource |
(same shape) |
| 19 | R:lifecycle:active |
B:publish_stack |
:types:lifecycle:active |
{at, prior_state: "new"} |
| 20 | S:active |
B:publish_stack |
:types:slot:active-pointer |
{run_id, at, prior_run_id: null} |
| 21 | R:complete |
B:publish_stack |
:types:run:complete |
{at, status: "ok", crossings_count: 20, duration_s} |
Notes on the table:
start_run writes #1–3, create_stack writes #4–8, publish_stack writes #9–21.capabilities: ["CAPABILITY_IAM"] even though the config doesn't pass them — create_stack parses the template, sees the SecurityGroup creation, and computes them.R:outputs:LoadBalancerDNSName directly.publish_stack. Folded together because they happen at the same moment: the deploy is complete, this run is now the active one in its slot. Prior model split these across multiple boundaries; consolidating them keeps the chain short for the MVP.prior_run_id: null. No sibling to drain for the first ALB deploy. When promotion arrives, this field carries the run-id getting drained.claims-artifact write. ALB MVP doesn't claim an artifact (CFN template is source, not artifact). Forward-compat shape stays in the address tree but no crossings get written for the artifact tree.The substrate's append-only nature plus run-id-keyed addressing gives us a clean replay-on-failure flow. Each Jenkins job is small, the deploy runtime is a Docker container, and boundaries skip themselves if the completion crossing they would have produced is already in the run's sub-stream.
Each pipeline job:
proquest-alb-mvp).vivarta-runner Docker image (cached on the agent).config.yml.Jenkins reads the result. On success, the job passes. On failure, Jenkins re-fires the same job with the same VIVARTA_RUN_ID env var.
docker run --rm \\
-e VIVARTA_RUN_ID=01HZJ9X8K5YQR4T6V8B2WFGM3Q \\
-v $PWD:/repo \\
-v advaita-data:/var/wanderland \\
vivarta-runner:latest --type cli /repo/config.yml deploy
The :advaita: SQLite mount is a named Docker volume (or EFS mount) shared across runs so prior crossings are visible.
The replay contract: each boundary checks for its completion crossing before doing work; if present, the boundary skips. Re-running with the same run-id picks up where the previous run failed.
VIVARTA_RUN_ID. start_run mints a fresh ULID and writes R:metadata.VIVARTA_RUN_ID from the failed run. start_run sees the existing R:metadata crossing and short-circuits. Each subsequent boundary checks its own completion signature and either skips (already done) or runs (work remaining).(from_addr, type_addr)Every pipeline boundary declares an idempotency signature: a (from_addr, type_addr) pair identifying the crossing it produces when complete. The check is:
Query the substrate for crossings under the run's sub-stream where
from_addr == this_boundary.identityandtype_addr == this_boundary.completion_type. If any exist, skip; otherwise run.
The query uses the existing wanderland-core context surface (context.signed.from_boundary(name).with_type_addr(type)), but reads against runtime.storage directly so prior crossings outside the current request context are visible.
Per-boundary completion signatures for the ALB walkthrough:
| Boundary | Completion signature (from_addr, type_addr) |
Skip means |
|---|---|---|
start_run |
(B:start_run, :types:run:metadata) |
run record already exists |
create_stack |
(B:create_stack, :types:cfn:create-complete) |
stack create already finished |
publish_stack |
(B:publish_stack, :types:run:complete) |
publish + lifecycle + active-pointer all done |
Boundaries that emit multiple intermediate crossings (e.g. create_stack writes change-set-created, change-set-executed, wait, create-complete) check only the terminal type. Mid-stream re-entry of a single boundary isn't supported — if it failed mid-way, the next replay starts that boundary fresh and writes new intermediate crossings. The terminal crossing is what marks the boundary skippable on later replays.
For boundaries that touch external state (CFN stack create), the inline-skip-on-completion isn't enough — the external call may have started but not finished. The boundary's own first action (after the skip check) handles this. For create_stack:
:types:cfn:create-complete present? → return early.describe_stacks for the deterministic stack name.CREATE_IN_PROGRESS)? → attach to it, wait, write the wait/complete crossings.create_stack, write the change-set crossings, wait.This pattern is per-boundary; it's not a substrate concern. Each boundary's author chooses the right reattachment strategy for its provider. The substrate just provides the addressable history.
The skip check uses the same (from, type) signature regardless of where it lives:
call(input) is a context query for its own completion signature; early return if present. Simplest to ship — no wanderland-core changes.when_shape extension. Boundary registers when_shape: { not_exists: { from: ..., type: ... } }; the chain walker skips automatically. Requires a new predicate type in the When/ShapeMatcher vocabulary.skip_if_complete injection wraps every pipeline boundary, performs the check, decides invoke-or-pass. One registration, applies to all.Pick when the first boundary lands. The contract — (from, type) is sufficient, signatures declared per-boundary — is what's load-bearing.
What to scrutinize before code lands:
R:<segment> carry every kind of crossing the run produces, or are there segments missing? Should R:provision:change-set:* be flatter or deeper?:types:cfn:* / :types:lifecycle:* / :types:run:* / :types:slot:* the right top-level prefixes? Do we want :types:provision:* instead of leaking cfn into the type space?publish_stack doing too much? It writes outputs, resources, lifecycle, active-pointer, and run-complete. That's five logical things in one boundary. Could split, but they all happen at the same moment and share state. Trade-off between chain length and boundary cohesion.R:outputs:LoadBalancerDNSName). One bag is fewer writes. Worth it?--deployment-config flag, the engine config stays stable and the deploy values move out. Sometime soon. Worth deciding on flag name and semantics before code.