Module: templates/* (+ internal/scaffold, internal/generate, internal/workspace) · Milestone: v2 · Effort: ~2.5w (new feature #18 — local-cloud engines; needs a FEATURES.md entry)
Numbering note: specs currently stop at 25 and features at 17. This spec claims 28 and forward-references its imperative sibling spec 29; specs 26/27 are unallocated gaps that must be reserved (or this pair renumbered to 26/27) before merge. FEATURES.md has no #18/#19 — this spec adds #18 and the sibling adds #19.
Extend the shared-engine set beyond postgres/redis/minio so a workspace can stand up a local cloud — AWS emulation (LocalStack: S3/SQS/SNS/Kinesis/DynamoDB/Lambda), event streaming (NATS JetStream, Kafka), and AMQP (RabbitMQ) — all as warm, ref-counted, tenant-isolated instances on the one devstack_shared network, instead of a duplicate broker per repo. Each engine is net-new (zero existing references anywhere in the tree) and ships as exactly one templates/<name>/template.yaml declaring provides:/exports:/defaultPort:/params:/service: — the same shared-engine contract the three built-ins already satisfy. This spec defines only the engine templates and their capability/version/host-port/healthcheck/tenant-scoping model; the imperative aws/nats/kafka/amqp resource commands that create buckets/queues/streams inside these engines are the sibling spec 29, and the data lifecycle (db snapshot/restore) is spec 15. Templates land first because spec 29 has nothing to provision against until the engines exist.
- One template per engine, capability-keyed.
provides:is the ledgerenginecolumn (spec 08); instances are keyed(engine, major)exactly likeshared-postgres-18(spec 03 §version-conflict). New capabilities:aws(LocalStack),nats,kafka,amqp(RabbitMQ). The DNS alias isshared-<name>(shared-localstack,shared-nats,shared-kafka,shared-rabbitmq) via the existingsharedAlias/sharedAttrresolver (internal/generate/resolver.go). exports:carries only non-secret attrs (host,port, plus engine extras likeendpoint,region,monitorPort/adminPort/mgmtPort). Anything secret (password,secretkey,secret,token) is rejected as an inline${ref}bysecretAttrs(resolver.go:16) and must flow throughenv.importas valueless per-service env keys filled at compose-up (spec 04 §7.5 coupling) — no plaintext on disk, asserted by the existing determinism/secret CI test.- Resolver extension is part of this spec.
host/port/endpoint/regionresolve from data the resolver already has (the DNS alias and the instance's single in-networkdefaultPortinsharedPort map[string]int, plus theregionparam). ButmonitorPort/adminPort/mgmtPortare secondary container ports (8222/9644/15672), which the resolver does not track today — it carries only one port per shared service. Exporting them requires a small per-template export-attr → port lookup (a static map from the template's declared secondary ports), not a literal hardcode insharedAttr. This is the one non-trivialinternal/generatechange; see Open questions. - Kafka = Redpanda, not Apache Kafka. Redpanda is a single C++ static-binary, Kafka-API-compatible broker with built-in schema registry and no JVM/ZooKeeper/KRaft-quorum ceremony — dramatically lighter and faster to a healthy state for local dev. We still
provides: kafka(the capability is the wire protocol, not the implementation) so${ref:workspace.shared.kafka}and the spec 29rpk-driven commands stay implementation-agnostic. (See gotchas for why plainapache/kafkaKRaft is the worse local default.) - Host-port policy per engine follows spec 03: default no published port (DNS over
devstack_shared). A127.0.0.1-only host port is published only when a host-side tool must reach the engine — and that publish is an up-time compose overlay (writeProvisionOverlay→.devstack/shared/compose.provision.yaml, the pattern provisioning already uses forpg-provisionport 45432), never the deterministicgenerateoutput. Each engine that needs host reachability gets its ownFreeHostPortpurpose + base (see table). Note the live signature isFreeHostPort(ctx, owner, purpose, base)(owner = the shared alias, e.g.shared-localstack), self-locking inside the flock — same call shape asinternal/orchestrate/provision.go. - LocalStack edition = community (
localstack/localstack, Apache-2.0), services selected via theSERVICESenv param. Pro/localstack-prois owner-opt-in via theimage/authTokenparams, never default (it needs a paid token throughenv.import). - Tenant scoping is per-engine and prefix-based where the engine namespace is global. Bucket names (LocalStack S3 / MinIO), Kinesis stream names, DynamoDB table names, SQS/SNS names, and Kafka topics are flat global namespaces inside one instance — every resource spec 29 creates is prefixed by the consumer project (
<proj>-<name>) and recorded in theprovisioned(ctx, project, kind, name)ledger viaRecordProvisioned(kindis free-text —bucket/queue/stream/topic/table/vhostneed no migration, joining the existingdb/role/bucket/redis_indexkinds). Project A can never name-collide with or reap project B's resources. ministackis a second AWS-emulation engine (owner-decided). It is the ministack.org container image — a lighter LocalStack alternative — authored as a normaltemplates/ministack/template.yamldeclaringprovides: aws(same capability as LocalStack, so the spec 29aws/s3/queueverbs are backend-agnostic). A workspace picks one AWS-emulation engine by template name; both key the ledger asengine=aws. Its exact image tag / edge port /SERVICESknob / health endpoint must be confirmed against ministack.org before authoring (zero references in the tree today).- No new pure-Go runtime SDK.
pgx/v5stays the only in-process runtime SDK. Every new engine tool (awslocal/aws,nats,rpk,rabbitmqadmin) is an external binary shelled out behind aninternal/interface with a mock and aCmdError(cmd+code+stderr)— thedocker/git/spec 15Dumperdiscipline — and is aninfo-leveldoctorprobe (absence degrades only the matching spec 29 verbs, never blocksup). The probes themselves are introduced by spec 29 alongside the verbs that use them; the templates here add zero Go deps and leave theCGO_ENABLED=0static binary untouched.
| Template | provides |
DNS alias | defaultPort (in-network) |
Host port? (purpose / base) | Stateful volume | Healthcheck |
|---|---|---|---|---|---|---|
localstack |
aws |
shared-localstack |
4566 (edge) | yes — host AWS SDK/aws CLI (localstack-host / 44566) |
localstackdata:/var/lib/localstack |
curl -sf localhost:4566/_localstack/health | grep running |
nats |
nats |
shared-nats |
4222 (client); 8222 monitor | yes for host nats CLI (nats-host / 44222); monitor 8222 host-opt |
natsdata:/data (JetStream) |
wget -qO- localhost:8222/healthz |
kafka (Redpanda) |
kafka |
shared-kafka |
9092 (Kafka API); 9644 admin | yes for host rpk/clients (kafka-host / 49092) |
kafkadata:/var/lib/redpanda/data |
rpk cluster health -x admin.hosts=localhost:9644 |
rabbitmq |
amqp |
shared-rabbitmq |
5672 (AMQP); 15672 mgmt UI | mgmt UI host-opt (rabbitmq-mgmt / 45672) |
rabbitmqdata:/var/lib/rabbitmq |
rabbitmq-diagnostics -q ping |
The advertised-listener subtlety (Kafka/Redpanda, NATS routes) is why these need two listeners — see gotchas. Host ports are
127.0.0.1-bound and ledger-allocated inside the flock (spec 03 §port-allocation) viaFreeHostPort(ctx, owner, purpose, base), unioned with Docker-published bindings.
schemaVersion: 1
description: "Shared LocalStack AWS-emulation engine, reached over the shared network at shared-localstack:4566."
provides: aws
exports: [host, port, endpoint, region]
defaultPort: 4566
params:
image:
type: string
default: "localstack/localstack:3"
description: "LocalStack community image; pin a major for (engine,major) ledger keying."
services:
type: string
default: "s3,sqs,sns,kinesis,dynamodb"
description: "Comma list for the SERVICES env (lazy-loads only these for fast, light startup)."
region:
type: string
default: "us-east-1"
service:
image: "[[ .params.image ]]"
restart: unless-stopped
environment:
SERVICES: "[[ .params.services ]]"
DEBUG: "0"
AWS_DEFAULT_REGION: "[[ .params.region ]]"
# Persist across restarts (community persistence is best-effort; see gotchas).
PERSISTENCE: "1"
volumes:
- "localstackdata:/var/lib/localstack"
healthcheck:
# /_localstack/health returns per-service readiness; gate on the edge being up.
test: ["CMD-SHELL", "curl -sf http://localhost:4566/_localstack/health | grep -q running"]
interval: 10s
timeout: 5s
retries: 8
start_period: 20s
volumes:
localstackdata: {}- In-network: a consumer sets
AWS_ENDPOINT_URL=http://shared-localstack:4566(resolved via${ref:workspace.shared.localstack.endpoint}→http://shared-localstack:4566;region→ the param). Host-side (spec 29aws/awslocal): thelocalstack-host/44566 overlay publishes127.0.0.1:44566:4566, and the resource command targets--endpoint-url http://127.0.0.1:44566. Credentials for emulation are the canonical dummy pair (test/test) — not secret, so they may be plainenvironment:on the consumer, but a generated-secret policy can still route them viaenv.import. endpointandregionare new export attrs, both non-secret and resolvable from data the resolver already has:sharedAttrgainscase "endpoint"→"http://" + sharedAlias(name) + ":" + sharedPort[name]andcase "region"→ the instance'sregionparam.
schemaVersion: 1
description: "Shared NATS engine with JetStream, reached over the shared network at shared-nats:4222."
provides: nats
exports: [host, port, monitorPort]
defaultPort: 4222
params:
version:
type: string
default: "2"
description: "NATS major version image tag (the (engine,major) key)."
service:
image: "nats:[[ .params.version ]]"
restart: unless-stopped
# -js enables JetStream (streams/queues/KV); -m 8222 enables the monitoring port.
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- "natsdata:/data"
healthcheck:
# The container ships no curl; use the bundled wget against the /healthz monitor route.
test: ["CMD-SHELL", "wget -qO- http://localhost:8222/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 5
volumes:
natsdata: {}- JetStream gives durable streams (event sourcing) and queue groups (work queues) — the two patterns spec 29
nats stream/consumerprovisions, prefixed<proj>-<name>. Monitoring on 8222 is in-network by default (monitorPortexport, which needs the secondary-port resolver lookup) and host-published only on request.
schemaVersion: 1
description: "Shared Kafka-compatible broker (Redpanda), reached over the shared network at shared-kafka:9092."
provides: kafka
exports: [host, port, adminPort]
defaultPort: 9092
params:
image:
type: string
default: "redpandadata/redpanda:v24.2.7"
description: "Single-binary Kafka-API broker; pin a version for (engine,major) keying."
service:
image: "[[ .params.image ]]"
restart: unless-stopped
command:
- "redpanda"
- "start"
- "--mode=dev-container" # single-node, no quorum ceremony
- "--smp=1"
- "--default-log-level=warn"
# TWO advertised listeners: in-network clients use the DNS alias; host tools
# use 127.0.0.1. A single advertised listener is the #1 Kafka-local footgun.
- "--kafka-addr=internal://0.0.0.0:9092,external://0.0.0.0:19092"
- "--advertise-kafka-addr=internal://shared-kafka:9092,external://127.0.0.1:49092"
volumes:
- "kafkadata:/var/lib/redpanda/data"
healthcheck:
test: ["CMD-SHELL", "rpk cluster health -x admin.hosts=localhost:9644 | grep -q 'Healthy:.*true'"]
interval: 10s
timeout: 5s
retries: 8
start_period: 15s
volumes:
kafkadata: {}- In-network clients connect to
shared-kafka:9092(theinternaladvertised listener). Host tools/rpkreach127.0.0.1:49092via theexternallistener — thekafka-host/49092 overlay publishes127.0.0.1:49092:19092. The two-listener split is mandatory: a broker advertises the address it tells clients to reconnect on after bootstrap, so one listener can satisfy either in-network DNS or host loopback, never both. rpkis the spec 29 host tool forkafka topic/acl; topics are prefixed<proj>-<name>and ledger-recordedkind=topic.
schemaVersion: 1
description: "Shared RabbitMQ (AMQP) engine with the management UI, reached at shared-rabbitmq:5672."
provides: amqp
exports: [host, port, mgmtPort]
defaultPort: 5672
params:
version:
type: string
default: "3"
description: "RabbitMQ major (the (engine,major) key); the -management tag adds the UI."
user:
type: string
default: devstack
service:
image: "rabbitmq:[[ .params.version ]]-management"
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: "[[ .params.user ]]"
# Password is secret → injected via env.import at compose-up, never inline.
RABBITMQ_DEFAULT_PASS:
volumes:
- "rabbitmqdata:/var/lib/rabbitmq"
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
volumes:
rabbitmqdata: {}RABBITMQ_DEFAULT_PASSis emitted as a valueless per-service env key (theenvironment: [NAME]form) and filled from the secrets provider at compose-up — the spec 04 coupling. The management UI (15672) is in-network by default (mgmtPortexport), host-published only on request (rabbitmq-mgmt/45672). Per-project isolation = a project-prefixed vhost (<proj>) + scoped user, provisioned by spec 29amqp vhost,kind=vhost.
Decided: ministack is the ministack.org container image — a real AWS-local-emulation runtime, a lighter sibling/alternative to LocalStack. So it is a normal shared engine template, NOT a preset: templates/ministack/template.yaml declaring provides: aws (the same capability as LocalStack, so a project's uses: workspace.shared.<name> and the spec 29 aws/s3/queue verbs are backend-agnostic — they target whichever AWS-emulation engine the workspace ran). A workspace picks one AWS-emulation engine (LocalStack or ministack) by template name; both key the ledger as engine=aws so only one (aws, major) instance is warm.
# templates/ministack/template.yaml (illustrative — confirm image tag/edge port/SERVICES knob against ministack.org docs)
schemaVersion: 1
description: "ministack — local AWS emulation (S3/SQS/SNS/…), a lighter LocalStack alternative."
provides: aws
exports: [endpoint, region] # same export contract as localstack (host-side --endpoint-url + region)
defaultPort: 4566 # CONFIRM: ministack's edge/gateway port
params:
image: { type: string, default: "ministack/ministack:latest" } # CONFIRM exact repo/tag
services: { type: string, default: "s3,sqs,sns" } # CONFIRM the service-selection env/knob
service:
image: "[[ .params.image ]]"
restart: unless-stopped
environment: { SERVICES: "[[ .params.services ]]" } # CONFIRM env var name
healthcheck: { test: ["CMD","curl","-fsS","http://localhost:4566/_localstack/health"], interval: 10s, timeout: 5s, retries: 10 } # CONFIRM health endpointBecause the AWS API surface is what matters, ministack reuses LocalStack's exact endpoint/region export attrs, the same 127.0.0.1 host-port overlay (own FreeHostPort purpose aws-host/base shared with localstack since only one is active), and the same project-prefixed bucket/queue naming for tenant isolation. The exact image tag, edge port, service-selection knob, and health endpoint must be confirmed against ministack.org before this template is authored (the recon found zero references anywhere; these are placeholders).
- Author/scaffold. New built-in cloud engines are added in-tree under
templates/<name>/template.yaml(thego:embedset). The user-facing authoring path istemplate new --kind engine --name <name> --provides <cap> --port <n> --exports host,port,...(spec 23); by defaulttemplate newwrites into the$DEVSTACK_HOME/templatesstore (pass--dir templatesto seed an in-tree built-in).internal/scaffoldrejects an emptyprovides:, so every shared engine here declaresprovides:+defaultPort:. Custom store copies under$DEVSTACK_HOME/templates/<name>/override the embedded built-in by name (the store chain ahead ofgo:embed). - Reference resolution. A consumer's
uses:/${ref:workspace.shared.<name>.<attr>}resolves throughgraphResolver.sharedAttr(resolver.go:82):host→shared-<name>,port→defaultPort, plus the new non-secretendpoint/region(from alias+defaultPort+param) andmonitorPort/adminPort/mgmtPort(from the new export-attr→secondary-port lookup). Any secret attr is rejected → must go throughenv.import. - Generate (deterministic). The compose model is built programmatically and validated by
compose-go/v2; the engine joinsdevstack_sharedwith itsshared-<name>alias; no host port appears in generated output.writeIfChanged+ SHA-256 rebuild hash, byte-identical (CI determinism lane). - Ledger keying. On
up, the engine registers asshared-<engine>-<major>keyed(engine, major)—shared-postgres-18style. A workspace pinning two majors of one engine gets two instances, ref-counted independently. - Up saga (unchanged order).
preflight → network → generate → secrets → trust → shared(health-gated) → provision(if targets>0) → hooks(spec 09). The new engines are ordinary shared services in thesharedphase; their healthchecks gate readiness (spec 10). No new saga phase is added by this spec. - Host-reachability overlay (only when a host tool needs it). When a host tool (spec 29 resource verb, or an explicit host-port request) needs the engine reachable from the host,
FreeHostPort(ctx, sharedAlias(inst), "<engine>-host", base)allocates inside the flock, and an up-time overlay.devstack/shared/compose.provision.yaml(written bywriteProvisionOverlay) publishes127.0.0.1:<port>:<container-port>— leaving deterministicgenerateoutput untouched, exactly likepg-provision(45432). Because the resource commands are standalone cobra commands that mirror the provisionPhase body (lock → host-port overlay → engine tool → ledger →LogEvent), not new saga phases, they own writing/refreshing this overlay outsideuptoo. - Tenant-scoped provisioning is spec 29: every resource is created
<proj>-<name>-prefixed via the engine's external tool behind aninternal/interface, recordedRecordProvisioned(ctx, project, kind, name)under the flock, and the ledger rows are reaped bydb gc'sOrphanedProvisioned(active)(spec 13) when the project is removed. (Reaping the row does not by itself delete the bucket/topic in the engine — the actual engine-side teardown is the spec 29 tool's job, driven off the same rows.)
- Kafka: do NOT default to
apache/kafkaKRaft. Plain Apache Kafka needs explicitKAFKA_NODE_ID/CONTROLLER_QUORUM_VOTERS/PROCESS_ROLESplus acluster-idand akafka-storage formatinit step before it will start as a single node — a long, fragile, JVM-heavy boot. Redpanda--mode=dev-containeris a single static binary that is healthy in seconds with no quorum config. We keepprovides: kafka(capability = wire protocol) so consumers/spec 29 are implementation-agnostic. - Advertised listeners must be split (Kafka & NATS routes). A broker hands clients the advertised address to reconnect on after bootstrap. One advertised address can serve in-network DNS (
shared-kafka:9092) or host loopback (127.0.0.1:49092), never both — so the template declares two listeners (internal/external). The naive single-listener config works from inside the network and silently fails the hostrpk(and vice-versa). - Host-side tools can NOT reach an in-network-only engine. With the default no-published-port policy a shared engine is only on
devstack_shared; any tool running from the host (the AWS SDK,aws/awslocal,nats,rpk,rabbitmqadmin, likepgxfor Postgres) needs the127.0.0.1up-time overlay first. This is the same constraint that forcespg-provisionto publish 45432 — the host pgx connection cannot resolveshared-postgres. - LocalStack health is per-service, not a flat 200.
GET /_localstack/healthreturns each service's state (available/running); a service inSERVICESis lazy and only flips torunningon first use. Gate the healthcheck on the edge being up (grep running), not on every service, or the container never reports healthy until traffic arrives. - LocalStack persistence is best-effort in community.
PERSISTENCE=1+ the/var/lib/localstackvolume survives restarts on a best-effort basis; deep state durability is a Pro feature. Document it: treat LocalStack data as recreatable, and let spec 29 re-create resources idempotently from theprovisionedledger rather than relying on the volume. - Bucket / stream / table / topic namespaces are FLAT and GLOBAL inside one instance — identical to the MinIO bucket caveat (spec 15). Two projects both asking for
uploadswould collide. Prefix every resource by project (<proj>-uploads) and key theprovisionedledger by(ctx, project, kind, name)so tenant isolation holds anddb gcreaps the right rows. - NATS image has no
curl. Healthcheck must use the bundledwget(or thenatsbinary if present) against/healthzon the-m 8222monitor port — aCMD curltest fails as "executable not found" and the engine never goes healthy. - RabbitMQ first boot is slow (Erlang VM + Mnesia schema) — set
start_period: 30sand userabbitmq-diagnostics -q ping(not a TCP probe), or the saga's health gate flaps and aborts a healthy bring-up. - Secrets never inline.
RABBITMQ_DEFAULT_PASS, LocalStack ProauthToken, and any generated broker credential aresecretAttrs(resolver.go:16) — emit valuelessenvironment: [NAME]and pass the value viaexec.Cmd.Envat compose-up (or push to a provider via the spec 04 Pusher). The existing CI test asserting no secret value lands in a generated file must pass for these templates. - External binaries are
info-level only.aws/awslocal,nats,rpk,rabbitmqadminare host tools behindinternal/seams (spec 29); their absence degrades only the matching resource verbs, neverup— themkcert/cloudflared/pg_dumpposture (DECISIONS D11/D12, spec 15). The templates add zero Go deps;CGO_ENABLED=0static binary is untouched. - Never recreate a stateful shared engine. A param change that would force-recreate
shared-kafka/shared-rabbitmq(e.g. a volume-path or listener change) is gated by the never-recreate guard (spec 03) — it would drop every tenant's connections. Bumping the major spins a new(engine, major)instance instead of mutating the running one. - Determinism vs runtime. Only generated artifacts are determinism-gated; the host-port overlay, ledger rows, and spec 29 resource creation are runtime mutations and are explicitly not byte-compared — same split as
pg-provision.
-
templates/localstack,templates/nats,templates/kafka,templates/rabbitmqeach passtemplate lint(non-emptyprovides:+defaultPort:, validservice:/healthcheck:) andtemplate testgolden render. - A workspace with
uses: workspace.shared.localstackbrings up exactly oneshared-localstackondevstack_shared, reachable in-network atshared-localstack:4566, with no host port in deterministicgenerateoutput. -
${ref:workspace.shared.localstack.endpoint}resolves tohttp://shared-localstack:4566and.regionto the param;${ref:...localstack.monitorPort}-style secondary-port attrs resolve via the new export-attr→port lookup; a${ref:...localstack.secret}(or anysecretAttrsattr) is rejected at generate time. - Two projects pinning
kafkamajor24vs25produce two ledger-keyed instancesshared-kafka-24/-25, ref-counted independently (spec 03 parity test). - Each engine reports healthy under its declared healthcheck within
start_period; the up saga's health gate passes (Redpanda viarpk cluster health, NATS via/healthz, RabbitMQ viarabbitmq-diagnostics ping, LocalStack via edgerunning). - A host-reachability request publishes a
127.0.0.1-only overlay port viaFreeHostPort(ctx, sharedAlias(inst), "<engine>-host", base)allocated inside the flock;generateoutput is byte-identical with and without the overlay. - Kafka template advertises two listeners; an in-network client reaches
shared-kafka:9092and hostrpkreaches127.0.0.1:49092in the same run. -
RABBITMQ_DEFAULT_PASSappears as a valueless per-service env key in generated compose; the secret value appears in no generated file (CI assertion). -
ministackis authored as an AWS-emulation engine template (provides: aws, the ministack.org image) once its image tag/edge port/SERVICESknob/health endpoint are confirmed against ministack.org; it is interchangeable with LocalStack by template name (bothengine=aws).
Consumes internal/scaffold (the provides:-required builder), internal/template (the [[ ]] engine + go:embed/store chain), internal/generate (sharedAttr/sharedAlias + the new non-secret export cases and the secondary-port lookup), internal/workspace ((engine, major) ledger keying, ref-counting/reconcile, FreeHostPort(ctx, owner, purpose, base)), internal/state+internal/lock (the provisioned free-text-kind ledger + flock), internal/secrets (valueless env injection for broker creds, spec 04). Consumed by spec 29 (the aws/nats/kafka/amqp resource commands that provision buckets/queues/streams/topics inside these engines — the imperative sibling, with spec 15 as the data-command precedent) and internal/doctor (the new info-level bin.aws/bin.nats/bin.rpk/bin.rabbitmqadmin probes, introduced with spec 29's verbs). Thin (~1w): LocalStack + NATS templates with health + host overlay + the new endpoint/region export attrs. Full (~2.5w): adds Kafka (Redpanda) + RabbitMQ, the secondary-port resolver lookup, the ministack AWS-emulation engine template (once ministack.org config is confirmed), and golden tests.
Q-MINISTACK — RESOLVED (owner): ministack is the ministack.org container image — a real AWS-local-emulation runtime, a lighter alternative to LocalStack. It is authored as a normal AWS-emulation engine template (provides: aws), interchangeable with LocalStack by template name (both key the ledger engine=aws). Remaining sub-task before authoring: confirm the exact image repo/tag, edge/gateway port, service-selection env knob, and health endpoint against the ministack.org docs (the recon found zero references; spec uses placeholders). Implementation note (not yet authored): the first cloud-engine batch ships localstack/nats/kafka/rabbitmq only; templates/ministack/ is deliberately deferred because its image tag/edge port/SERVICES knob/health endpoint are unconfirmed offline — authoring it with a guessed image would key the aws engine to a non-existent container. It lands once a maintainer confirms the ministack.org config; LocalStack already satisfies the aws capability in the meantime.
Q-SECONDARY-PORTS — how do monitorPort/adminPort/mgmtPort resolve, given the resolver tracks only one in-network port per shared service (sharedPort map[string]int)? Recommendation: add a small static per-template export-attr→port map in internal/generate (driven off the template's declared secondary ports), not a hardcode in sharedAttr. Decision: owner to confirm whether secondary-port exports ship in v2 or are deferred (host-published mgmt/monitor UIs work without a ${ref} export).
Q-KAFKA-RUNTIME — Redpanda vs Apache Kafka KRaft as the kafka engine. Recommendation: Redpanda (single static binary, no JVM/quorum, healthy in seconds; provides: kafka keeps consumers implementation-agnostic). Decision: owner to confirm Redpanda as the default kafka image, with an image: param escape hatch to apache/kafka for parity testing.
Q-LOCALSTACK-EDITION — community default vs Pro opt-in. Recommendation: community (localstack/localstack, Apache-2.0) default; Pro only via owner-set image+authToken (token through env.import). Decision: owner to confirm community-only ships v2; Pro support is param-gated, not bundled.
Q-SPEC-NUMBERING — specs 26/27 are unallocated and this pair claims 28/29. Decision: owner to reserve 26/27 (or renumber this pair to 26/27) and add the FEATURES.md #18/#19 rows before merge.
Q-DAEMON (../OPEN-QUESTIONS.md) — no daemon → these engines stay warm with no autostop; shared gc reclaims zero-ref instances on demand (inherited, not re-argued here).