# Your first flow with the Terraform provider This tutorial deploys a contact flow written in HCL to an Amazon Connect instance with the `flow-as-code/flowascode` provider, then shows what the provider does at plan time that a rendered JSON blob cannot: it lints the flow, and it shows a change made in the Connect console as a change to one action. It uses `examples/terraform-provider/` from the repository. Every output below is from a run against a live instance on 2026-09-29 with Terraform 1.8.5 and provider v0.1.0, with account and instance identifiers masked. ## What you need - Terraform 1.8 or later, or OpenTofu 1.10 or later: the provider is on both registries, and every `terraform` command below is the same with `tofu`. - Credentials for an AWS account with an Amazon Connect instance, and the instance's id (the last segment of its ARN). - A clone of the repository, for the example's files. ## Step 1: declare the provider The provider takes the same configuration vocabulary as `hashicorp/aws` (region, profile, `assume_role`, endpoints), so one region and one set of credentials serve both. `envs/dev/providers.tf`: ```hcl terraform { required_version = ">= 1.8.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } flowascode = { source = "flow-as-code/flowascode" version = "~> 0.1" } } } provider "aws" { region = "us-east-1" } provider "flowascode" { region = "us-east-1" } ``` ## Step 2: read the flow `flows/appointment-line.flow.tf` is one `flowascode_contact_flow` resource with one `action` block per action, in order. An action is its `id`, its `next` action, one sub-block named after its Connect type, and its branches: ```hcl action { id = "set-working-queue" next = "look-up-appointment" update_contact_target_queue { queue_id = "queue:appointments" } error { type = "NoMatchingError" next = "apologize" } } ``` `update_contact_target_queue` is the Flow language's `UpdateContactTargetQueue` in snake case, and `queue_id` is its `QueueId` parameter. Every action type the tooling models has a typed block like this; one it does not model yet goes in a `generic` block with its type and its parameters as `jsonencode(...)`, unchanged. `"queue:appointments"` is a reference key, not an ARN. The resource's `refs` map binds each key once to a Terraform expression, here a module variable: ```hcl refs = { "hours:main-line" = var.main_line_hours_arn "lambda:appointment-lookup" = var.appointment_lookup_arn "queue:appointments" = var.appointments_queue_arn } ``` That split is the point. The actions describe the flow and are the same in every environment; `refs` says which queue `queue:appointments` is here, and it is the only thing an environment changes. ## Step 3: plan ``` cd examples/terraform-provider/envs/dev export TF_VAR_connect_instance_id= terraform init ``` ``` Initializing modules... Initializing provider plugins... - Installing flow-as-code/flowascode v0.1.0... - Installed flow-as-code/flowascode v0.1.0 (self-signed, key ID 2C8E2160F58950AC) - Installing hashicorp/aws v6.66.0... - Installed hashicorp/aws v6.66.0 (signed by HashiCorp) Partner and community providers are signed by their developers. Terraform has been successfully initialized! ``` The key id is the project's release signing key; `SECURITY.md` in the provider repository gives its full fingerprint. ``` terraform plan -out=tfplan ``` The plan shows the flow twice over. `flowdoc` is the flow as a FlowDoc, the interchange format every flow-as-code tool reads, with its references still tokens: ``` # module.flows.flowascode_contact_flow.appointment_line will be created + resource "flowascode_contact_flow" "appointment_line" { + arn = (known after apply) + contact_flow_id = (known after apply) + content = (known after apply) + content_hash = (known after apply) + flowdoc = jsonencode( { + connectType = "CONTACT_FLOW" + content = { + Actions = [ ... + QueueId = "${cdref:queue:appointments}" ``` `content` is what Connect will hold, with each token replaced by the ARN its `refs` entry resolves to; it is known after apply because the queue does not exist yet. The plan ends: ``` + name = "appointment-line" + refs = { + "hours:main-line" = (known after apply) + "lambda:appointment-lookup" = (known after apply) + "queue:appointments" = (known after apply) } + state = (known after apply) + type = "CONTACT_FLOW" Plan: 6 to add, 0 to change, 0 to destroy. ``` ## Step 4: apply ``` terraform apply tfplan ``` ``` aws_connect_hours_of_operation.main_line: Creation complete after 1s [id=...] aws_connect_queue.appointments: Creation complete after 0s [id=...] aws_iam_role.appointment_lookup: Creation complete after 1s [id=...] aws_lambda_function.appointment_lookup: Creation complete after 15s [id=...] aws_connect_lambda_function_association.appointment_lookup: Creation complete after 0s [id=...] module.flows.flowascode_contact_flow.appointment_line: Creation complete after 1s [id=...] Apply complete! Resources: 6 added, 0 changed, 0 destroyed. Outputs: appointment_line_document_sha256 = "3d12a6cd22f8e04018712c78915e89cef32b09d60849b573a7af41f14a641e65" ``` The flow is published in Connect, named `appointment-line`. A second `terraform plan` finds nothing to change. ## Step 5: let the plan catch mistakes The provider runs the flow-as-code lint rules at plan time, before anything reaches Connect. Most rules warn; the hard rules, the ones about references, fail the plan. Delete the `error` block from `set-working-queue` and plan: ``` Warning: error-branches with module.flows.flowascode_contact_flow.appointment_line, on ../../flows/appointment-line.flow.tf line 4, in resource "flowascode_contact_flow" "appointment_line": 4: resource "flowascode_contact_flow" "appointment_line" { action "set-working-queue": UpdateContactTargetQueue does not wire its "NoMatchingError" branch. ``` Connect would refuse this flow at apply, so treat the warning as a stop. (A rule you have decided does not apply to a flow can be switched off for that resource with `lint { disable = ["rule-id"] }`; the hard rules cannot.) Put the block back, and paste a queue's ARN where the key was: ```hcl update_contact_target_queue { queue_id = "arn:aws:connect:us-west-2:111122223333:instance/11111111-2222-3333-4444-555555555555/queue/66666666-7777-8888-9999-000000000000" } ``` ``` Error: LITERAL_ARN with module.flows.flowascode_contact_flow.appointment_line, on ../../flows/appointment-line.flow.tf line 61, in resource "flowascode_contact_flow" "appointment_line": 61: queue_id = "arn:aws:connect:us-west-2:111122223333:instance/11111111-2222-3333-4444-555555555555/queue/66666666-7777-8888-9999-000000000000" action[set-working-queue].update_contact_target_queue.queue_id holds a literal ARN; write a reference key such as "queue:" and bind it in refs. ``` The plan fails. An ARN in a flow is the thing that makes a flow impossible to promote, so it never gets as far as a plan. ## Step 6: see a console edit as a diff Someone changes the welcome message in the Connect console. The next plan reads the live flow back as action blocks and shows the change where it is: ``` Note: Objects have changed outside of Terraform ... # module.flows.flowascode_contact_flow.appointment_line will be updated in-place ~ resource "flowascode_contact_flow" "appointment_line" { ~ action { id = "welcome" # (1 unchanged attribute hidden) ~ message_participant { ~ text = "Thanks for calling Example Clinic." -> "Thanks for calling. Let's get you to the right place." } # (1 unchanged block hidden) } # (10 unchanged blocks hidden) } Plan: 0 to add, 1 to change, 0 to destroy. ``` `apply` puts the reviewed text back. To keep the console's edit instead, copy the new text into the `.flow.tf` and plan again; the plan is then empty. ## Step 7: clean up ``` terraform destroy ``` ## Where next - [Promote this flow to prod](02-promote.md), from the same commit. - [The flow cookbook](../../examples/terraform-provider/cookbook/README.md): menus, callbacks, Lambda routing, modules and more, each applied live. - [Bring existing flows under Terraform](03-adopt.md) if your flows already exist in Connect. - [The provider and the HCL view](../06-terraform-provider.md), the reference. --- # Promote a flow from dev to prod A contact flow is promoted when prod runs exactly what dev ran, bound to prod's own queues, hours and functions. With the Terraform provider that means one copy of the flow, in a Terraform module, applied from the same commit by each environment's root module. Nothing is copied, exported, find-and-replaced or re-pasted between environments. This tutorial continues [Your first flow with the Terraform provider](01-first-flow.md) and uses the same `examples/terraform-provider/`. Outputs are from a live run on 2026-09-29 (Terraform 1.8.5, provider v0.1.0), identifiers masked. ## The shape: one module, one root per environment ``` flows/ the flows, once: a Terraform module envs/dev/ dev's root module: providers, backend, dev's resources envs/prod/ prod's root module: providers, backend, prod's resources ``` The flow refers to what it uses by key (`"queue:appointments"`), and binds each key in `refs` to a variable of the module. Each root module calls the module and says what the variables are in its environment. That call is the only place the environments differ. Dev creates its own resources: ```hcl module "flows" { source = "../../flows" connect_instance_id = var.connect_instance_id main_line_hours_arn = aws_connect_hours_of_operation.main_line.arn appointment_lookup_arn = aws_lambda_function.appointment_lookup.arn appointments_queue_arn = aws_connect_queue.appointments.arn depends_on = [aws_connect_lambda_function_association.appointment_lookup] } ``` Prod's are owned by a platform team, who publish them as outputs of their own configuration, and prod reads their state: ```hcl data "terraform_remote_state" "platform" { backend = "local" config = { path = "../platform/terraform.tfstate" } } module "flows" { source = "../../flows" connect_instance_id = var.connect_instance_id main_line_hours_arn = data.terraform_remote_state.platform.outputs.main_line_hours_arn appointment_lookup_arn = data.terraform_remote_state.platform.outputs.appointment_lookup_arn appointments_queue_arn = data.terraform_remote_state.platform.outputs.appointments_queue_arn } ``` In a real organization the remote state is an S3 or HCP Terraform backend, not a sibling directory. Neither environment writes an ARN into a file: each value is an address Terraform resolves. ## Step 1: apply dev As in the first tutorial: ``` cd examples/terraform-provider/envs/dev terraform apply ``` ``` Apply complete! Resources: 6 added, 0 changed, 0 destroyed. Outputs: appointment_line_document_sha256 = "3d12a6cd22f8e04018712c78915e89cef32b09d60849b573a7af41f14a641e65" ``` `appointment_line_document_sha256` comes from the module's `outputs.tf`, which is what `flow-cli emit --target flowascode` writes beside `flows.tf` for every document, named by its FlowDoc name (a test holds the example's copy to the emitter's): ```hcl output "appointment_line_document_sha256" { description = "SHA-256 of flow appointment-line as a FlowDoc, references still tokens: equal across environments that apply the same document." value = sha256(flowascode_contact_flow.appointment_line.flowdoc) } ``` `flowdoc` is the flow as a FlowDoc with its references still tokens, so it names no environment. Its hash is the flow's identity across environments. (`content_hash` would not do: it hashes the content Connect holds, in which each environment's ARNs are filled in.) ## Step 2: apply prod The platform team's configuration exists first; in the example it is `envs/platform/`, which creates the same kinds of resources and publishes their ARNs: ``` cd ../platform && terraform apply Apply complete! Resources: 5 added, 0 changed, 0 destroyed. ``` Then prod: ``` cd ../prod terraform plan -out=tfplan ``` ``` Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + appointment_line_document_sha256 = "3d12a6cd22f8e04018712c78915e89cef32b09d60849b573a7af41f14a641e65" ``` The hash is known at plan time and equals dev's. Prod is about to run the document dev runs, before anything changes in prod. ``` terraform apply tfplan ``` ``` Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: appointment_line_document_sha256 = "3d12a6cd22f8e04018712c78915e89cef32b09d60849b573a7af41f14a641e65" ``` In Connect, the two flows differ in exactly the ARNs: prod's `UpdateContactTargetQueue` names the platform's queue, dev's names dev's. ## Step 3: let a pipeline do it `examples/terraform-provider/ci/promote-flows.yml` is a GitHub Actions workflow for this layout. A pull request plans both environments, so lint findings and the diff for each environment show up in review. A merge to `main` applies dev, then plans prod and stops unless prod's plan carries the document dev applied: ```yaml - name: prod runs the document dev ran env: DEV_DOCUMENT: ${{ needs.dev.outputs.document }} run: | prod=$(terraform show -json tfplan | jq -r '.planned_values.outputs.appointment_line_document_sha256.value') if [ "$prod" != "$DEV_DOCUMENT" ]; then echo "::error::prod would run flow document $prod; dev ran $DEV_DOCUMENT" exit 1 fi ``` and then waits for a reviewer on the `prod` environment before applying. It authenticates to AWS through GitHub's OIDC provider, so no AWS key is stored anywhere. The file's header lists the roles and variables it expects. Rolling back is the same pipeline: revert the commit, merge, and the reverted document goes through dev and then prod. ## Another layout: look resources up by name If both environments name their resources the same way, one root module can find them with `hashicorp/aws` data sources, and the environments differ only in the instance id and the credentials: ```hcl data "aws_connect_hours_of_operation" "main_line" { instance_id = var.connect_instance_id name = "main-line" } data "aws_connect_queue" "appointments" { instance_id = var.connect_instance_id name = "appointments" } data "aws_lambda_function" "appointment_lookup" { function_name = "appointment-lookup" } module "flows" { source = "../flows" connect_instance_id = var.connect_instance_id main_line_hours_arn = data.aws_connect_hours_of_operation.main_line.arn appointment_lookup_arn = data.aws_lambda_function.appointment_lookup.arn appointments_queue_arn = data.aws_connect_queue.appointments.arn } ``` Run it once per environment with that environment's credentials and `TF_VAR_connect_instance_id`, from a separate state per environment (a workspace each, or a backend key each). This configuration was validated against the providers' schemas; it was not applied in the run above. ## Shared modules: versions and aliases A flow module that several flows invoke promotes the same way, with one more step. The flow does not invoke the module's latest content; it invokes an alias, and the alias points at a version: ```hcl resource "flowascode_contact_flow_module_version" "greeting" { instance_id = var.connect_instance_id contact_flow_module_id = flowascode_contact_flow_module.greeting.contact_flow_module_id content_hash = flowascode_contact_flow_module.greeting.content_hash description = "Greeting as reviewed" lifecycle { create_before_destroy = true } } resource "flowascode_contact_flow_module_alias" "greeting_live" { instance_id = var.connect_instance_id contact_flow_module_id = flowascode_contact_flow_module.greeting.contact_flow_module_id name = "live" contact_flow_module_version = flowascode_contact_flow_module_version.greeting.version description = "The version callers hear" } ``` and the flow binds `module:greeting@live` to the alias: ```hcl refs = { "module:greeting@live" = flowascode_contact_flow_module_alias.greeting_live.arn } ``` When the module's content changes, the plan replaces the version (it is keyed to `content_hash`), creates the new one first, moves the alias to it in place, then deletes the old one. Connect refuses to delete a version an alias still points at, which is why `create_before_destroy` is not optional here. The alias's `arn` is the module ARN qualified by the alias id, the only form Connect runs as the alias. Put these resources in the flows module and they promote with it: dev's alias moves when dev applies, prod's when prod does. `examples/terraform-provider/cookbook/` has all three resources, applied live. ## Other deploy paths The same idea works without the provider: - `flow-cli emit --target flowascode --address-map refs.prod.tfmap.json` writes these resources from a FlowDoc, with each environment's addresses in a small JSON map; `--target tf` writes `aws_connect_contact_flow` resources for `hashicorp/aws` instead. - The CDK construct takes a binder per stack. [Promote one flow across two environments](../../examples/promote-across-environments/README.md) runs all three from one document, with a test holding that the environments differ in exactly one file. ## Checklist - The flow's actions name reference keys; only `refs` holds addresses. - The flows live in a module with no provider block. - Each environment has its own root module, state and credentials. - Plans run on pull requests, for every environment. - Prod applies the commit dev applied, and the document hash says so. --- # Bring existing flows under Terraform Most instances already have flows, built in the console or deployed some other way. This tutorial takes them over without recreating them: a flow keeps its id, its ARN and anything that points at it (phone numbers, other flows' transfers), and from then on changes go through a plan. There are three ways in, depending on where the flows are managed today: | Today | Way in | | ------------------------------------------ | -------------------------------------------------------------------------- | | In the console, a few flows | an `import` block per flow | | In the console, many flows | `flow-cli export --author tf` for the whole instance, then `import` blocks | | As `aws_connect_contact_flow` in Terraform | a `moved` block per flow | Outputs below are from a sandbox instance on 2026-09-29 (Terraform 1.8.5, provider v0.1.0, `@flow-as-code/cli` 0.2.0), identifiers masked. ## A few flows: import blocks Write an `import` block with the flow's id, `:` (both are in the flow's ARN), and let Terraform generate the configuration: ```hcl import { to = flowascode_contact_flow.appointment_line id = ":" } ``` ``` terraform plan -generate-config-out=generated.tf ``` ``` Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. ``` `generated.tf` holds the flow as action blocks: ```hcl resource "flowascode_contact_flow" "appointment_line" { description = null display_name = null instance_id = "" name = "appointment-line" refs = { "hours:main-line" = "arn:aws:connect:us-west-2::instance//operating-hours/" "lambda:appointment-lookup" = "arn:aws:lambda:us-west-2::function:appointment-lookup" "queue:appointments" = "arn:aws:connect:us-west-2::instance//queue/" } ... action { id = "welcome" next = "check-hours" error { next = "apologize" type = "NoMatchingError" } message_participant { media = null prompt_id = null ssml = null text = "Thanks for calling. Let's get you to the right place." } } ``` The provider read each ARN in the flow, found what it names in the instance's inventory, and wrote a reference key in its place: the actions say `"queue:appointments"`, not the queue's ARN. The ARNs are left in `refs`, because Terraform needs a value there and the provider cannot know which of your resources each one is. Three things to tidy before you commit: 1. **Replace each `refs` value with the address that manages it.** An ARN left in `refs` works, but it is the per-environment table this tooling exists to delete, and the studio refuses it. Write `aws_connect_queue.appointments.arn`, a `data` source, remote state, or a module variable, as [the promotion tutorial](02-promote.md) does. 2. **Replace `instance_id` with a variable** (`var.connect_instance_id`). 3. **Leave the rest as it is.** The `null` attributes (`settings = null`, `next = null` and the others) read as unset, in Terraform and in the studio and `flow-cli` alike, and the alphabetical order of the blocks is only order. To edit the flow in the studio too, move its resource into a file of its own named after it, `appointment-line.flow.tf` (one flow per file); with the two edits above made, `npx flow-cli synth appointment-line.flow.tf` reads it to its FlowDoc, and a save in the studio rewrites it in the canonical form. Export (below) writes the canonical form directly. Then plan again. When the configuration describes the flow as it is, the plan is empty and nothing in Connect changes. A flow named in the console as "Main Line" keeps that name: `name` becomes the slug `main-line`, which identifies the flow to the tooling, and `display_name = "Main Line"` is what Connect shows. Import writes both; the first apply renames nothing. ## Many flows: export the instance `flow-cli export` reads every flow and module in an instance and writes a FlowDoc and a companion for each. With `--author tf` the companion is a `.flow.tf` in the provider's canonical form, references already keys: ``` npx flow-cli export --instance --out flows/ --author tf ``` ``` default-agent-transfer (flow): default-agent-transfer.flowdoc.json, default-agent-transfer.flow.tf default-outbound (flow): default-outbound.flowdoc.json, default-outbound.flow.tf sample-queue-configurations-flow (flow): sample-queue-configurations-flow.flowdoc.json, sample-queue-configurations-flow.flow.tf sample-ab-test (flow): sample-ab-test.flowdoc.json, sample-ab-test.flow.tf sample-note-for-screenpop (flow): sample-note-for-screenpop.flowdoc.json, sample-note-for-screenpop.flow.tf failed: Sample Lambda integration (arn:aws:connect:us-west-2::instance//contact-flow/): Cannot export "sample-lambda-integration": 1 ARN(s) not found in the instance inventory: arn:aws:lambda:us-east-1::function:state-lookup Exported 19 of 20 to flows/ 1 flow(s) could not be exported ``` The failure is the tool refusing to guess. That flow invokes a Lambda function the instance's inventory does not list (it points at another region), and rather than write its ARN into the document, export names it and moves on. Associate the function with the instance, or fix the flow, and export again. One exported file, abridged: ```hcl resource "flowascode_contact_flow" "sample_note_for_screenpop" { instance_id = var.connect_instance_id name = "sample-note-for-screenpop" display_name = "Sample note for screenpop" type = "CONTACT_FLOW" description = "Screenpop is a Contact control pannel feature that allows loading a web page optionally with parameters based on attributes. Refer to the screenpop documentation for more information." start = "2b6ee9f2-0f1a-417c-91c7-bdc12cfc6a07" refs = { # TODO: no terraform address for ${cdref:queue:basicqueue}. "queue:basicqueue" = null } action { id = "35dde84a-f902-4a2b-9e0d-b6079d2a5260" next = "e8f664fd-3e86-4aaa-915f-c1fa4a4d4dd0" transfer_contact_to_queue {} error { type = "NoMatchingError" next = "e8f664fd-3e86-4aaa-915f-c1fa4a4d4dd0" } error { type = "QueueAtCapacity" next = "e8f664fd-3e86-4aaa-915f-c1fa4a4d4dd0" } position { x = 928 y = 85 } } ... } ``` The console's action ids and block positions are kept, so the studio shows the flow as the console drew it. Each `refs` entry arrives as `null` under a `TODO`; the provider refuses a plan with a `null` binding and names the key, so none can be forgotten. Fill them in, add an `import` block per flow, and plan until it is empty. `flow-cli lint flows/` runs the same rules the provider runs at plan time, without a plan. On a freshly exported instance it is a quick way to see which console flows would not survive a redeploy as they are. ## From hashicorp/aws: moved blocks A flow managed today as `aws_connect_contact_flow` moves to the provider without being recreated. Replace the resource with a `flowascode_contact_flow` describing the same flow, and add: ```hcl moved { from = aws_connect_contact_flow.appointment_line to = flowascode_contact_flow.appointment_line } ``` Terraform 1.8 and OpenTofu 1.10 carry the state across resource types. The move copies the flow's identity and name; the next plan writes the actions from your configuration, and when they describe the flow as it is, nothing changes in Connect. `aws_connect_contact_flow_module` moves to `flowascode_contact_flow_module` the same way. To write the new resource from the old one's content, run the content through flow-cli: `flow-cli export --author tf` reads the live flow, or, for a `.tftpl` file the `tf` emitter wrote, the FlowDoc it was emitted from is already in your repository and `flow-cli codegen --to tf` writes the companion. ## Afterwards - [Promote the flows](02-promote.md) from a module, one root per environment. - [Edit them in the studio](04-studio.md): a `.flow.tf` and its FlowDoc are a pair the studio keeps in step. - The provider's registry guide `migrate-from-aws-provider` covers the same ground from the provider's side. --- # Edit a .flow.tf in the studio A flow authored for the Terraform provider does not have to be edited as HCL. Every flow-as-code flow is a FlowDoc, and a FlowDoc has one companion source file, `.flow.ts` (typed TypeScript) or `.flow.tf` (the provider's resource). The studio, the visual editor, keeps the document and its companion in step in both directions: move a block on the canvas and the `.flow.tf` is rewritten; edit the `.flow.tf` and the canvas follows within a second. This tutorial needs `@flow-as-code/cli` 0.2.0 or later and Node 22.12 or later. Outputs are from a run on 2026-09-29; the CLI prints absolute paths, shortened here to `flows/...`. ## Step 1: start a flow authored in HCL ``` npm i -D @flow-as-code/cli npx flow-cli init flows/ --author tf ``` ``` flows/appointment-line.flow.tf flows/appointment-line.flowdoc.json ``` `init` writes a small demo flow as a FlowDoc and its `.flow.tf`. To work on flows you already have, `flow-cli export --author tf` writes the same pair for every flow in an instance ([Bring existing flows under Terraform](03-adopt.md)), and `flow-cli codegen --to tf` writes the `.flow.tf` for a FlowDoc you already have. ``` npx flow-cli lint flows/ ``` ``` No findings. ``` `lint` runs the same rules the provider runs at plan time, on the documents, with no AWS account and no Terraform. ## Step 2: open the studio ``` npx flow-cli studio flows/ ``` The studio opens in your browser on the directory. The toolbar badges the open document's companion (`.flow.tf`), and the lint panel shows the same findings `flow-cli lint` prints, including any rules the `.flow.tf` switches off in its `lint` block. The studio works offline; it makes no network call unless you connect it to an instance. ## Step 3: edit on the canvas Drag a block, add an action from the palette, or change a message in the inspector, and save. The studio refuses a save while a hard lint rule fails, and otherwise writes the FlowDoc and regenerates `appointment-line.flow.tf`. Regeneration writes the structure from the document and keeps what only the `.flow.tf` knows: - the `refs` bindings, by key (a key no action uses any more is dropped; a new key is written as `null` under a `# TODO` line, which the provider refuses at plan time until you bind it); - `instance_id`, `tags`, `state`, the `lint` block, `depends_on`, `provider` and `lifecycle`; - comments marked `@keep` directly above an action block or above the resource. So bindings written for an environment survive a canvas edit: ```hcl refs = { "hours:main-line" = var.main_line_hours_arn "lambda:appointment-lookup" = var.appointment_lookup_arn "queue:appointments" = var.appointments_queue_arn } ``` That is how `examples/terraform-provider/flows/` works: the directory is a Terraform module and a studio directory at once. A block keeps its canvas position in a `position { x y }` block only when it is somewhere other than where the automatic layout would put it, so a flow nobody has dragged has no positions in its `.flow.tf` at all. ## Step 4: edit the HCL Edit `appointment-line.flow.tf` in your editor, for example the welcome text: ```hcl message_participant { text = "Thanks for calling Example Clinic." } ``` and save. The canvas re-syncs within a second. If the file does not parse, or breaks a contract rule (an ARN where a key belongs, two typed blocks in one action), the studio says which line and keeps the last good canvas. Editing both sides at once is caught rather than merged: a canvas save over a file that changed on disk opens a conflict dialog that shows both versions as FlowDocs. ## Step 5: switch to TypeScript, or back A team that prefers typed TypeScript can switch a flow's companion: ``` npx flow-cli convert flows/appointment-line.flowdoc.json --to ts ``` ``` flows/appointment-line.flow.ts flows/appointment-line.flowdoc.json removed flows/appointment-line.flow.tf note: appointment-line.flow.tf's refs bindings, instance_id, tags, state and lint settings have no place in a .flow.ts and are not carried. ``` `convert` says what it drops. The flow itself is unchanged: the FlowDoc is the same file before and after. `--to tf` goes the other way, and writes `null` bindings for you to fill in. `--keep-old` keeps the old companion. ## What round-trips The FlowDoc is the source of truth; both companions are views of it. `synth(codegen(doc))` equals `doc`, and regenerating a companion from what it just wrote reproduces it byte for byte. Both are tested on every push, over every fixture in `conformance/`. An action type the tooling does not model is carried as a `generic` block (HCL) or a `GenericBlock` (TypeScript) with its parameters unchanged, and shows on the canvas as a generic node: nothing is dropped. --- # The flow cookbook Common contact flow patterns, each a complete `flowascode_contact_flow` resource for the Terraform provider. Every recipe in this directory was applied to a live Amazon Connect instance on 2026-09-29 (Terraform 1.8.5, provider v0.1.0), planned again with no changes, and destroyed. `tests/terraformProviderExample.test.ts` holds, on every push, that each one reads back through the HCL reader, is already `terraform fmt` output, lints with no findings, and appears on this page exactly as it is in its file. The recipes refer to their queues, hours and function by key (`"queue:support"`), and each binds its keys in `refs` to the resources in `supporting.tf`. In your configuration, bind them to your own resources, data sources or module variables; the flows do not change. Two things every recipe gets right, because Connect refuses a flow that gets them wrong: each action wires the error branches its type requires (the provider's lint says which, at plan time), and each reference is a key, never an ARN. To run them all: `terraform apply` in this directory, with `TF_VAR_connect_instance_id` set and a `customer-lookup.zip` for the function. ## Route by business hours Check an hours of operation, transfer during them, and say so outside them. `check_hours_of_operation` takes exactly the two conditions `True` and `False`, plus the catch-all. Every transfer needs both `QueueAtCapacity` and `NoMatchingError`: Connect refuses a `TransferContactToQueue` without either. `business-hours.tf`: ```hcl # Route to a queue during business hours; otherwise say so and hang up. resource "flowascode_contact_flow" "business_hours" { instance_id = var.connect_instance_id name = "business-hours" type = "CONTACT_FLOW" refs = { "hours:support" = aws_connect_hours_of_operation.support.arn "queue:support" = aws_connect_queue.support.arn } action { id = "check-hours" next = "closed" check_hours_of_operation { hours_of_operation_id = "hours:support" } condition { operator = "Equals" operands = ["True"] next = "set-queue" } condition { operator = "Equals" operands = ["False"] next = "closed" } error { type = "NoMatchingError" next = "closed" } } action { id = "set-queue" next = "transfer" update_contact_target_queue { queue_id = "queue:support" } error { type = "NoMatchingError" next = "closed" } } action { id = "transfer" next = "closed" transfer_contact_to_queue {} error { type = "QueueAtCapacity" next = "closed" } error { type = "NoMatchingError" next = "closed" } } action { id = "closed" next = "hang-up" message_participant { text = "We are closed. Our hours are nine to five, Monday to Friday." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## A keypad menu `get_participant_input` with `store_input = "False"` branches on the key pressed: one `condition` per key. That form needs `InputTimeLimitExceeded` (no key pressed) and `NoMatchingCondition` (a key no condition names) as well as the catch-all, and `store_input` must be written out; the `conditional-shape` rule holds all three. `keypad-menu.tf`: ```hcl # A keypad menu. With StoreInput "False", Connect branches on the key pressed, # and requires the InputTimeLimitExceeded branch (no key pressed) and the # NoMatchingCondition branch (a key no condition names). StoreInput must be # written out: the service refuses the action without it. The conditional-shape # lint rule holds all three. resource "flowascode_contact_flow" "keypad_menu" { instance_id = var.connect_instance_id name = "keypad-menu" type = "CONTACT_FLOW" refs = { "queue:priority" = aws_connect_queue.priority.arn "queue:support" = aws_connect_queue.support.arn } action { id = "menu" next = "not-understood" get_participant_input { text = "For support, press 1. For an existing case, press 2." input_time_limit_seconds = 5 store_input = "False" } condition { operator = "Equals" operands = ["1"] next = "to-support" } condition { operator = "Equals" operands = ["2"] next = "to-priority" } error { type = "InputTimeLimitExceeded" next = "not-understood" } error { type = "NoMatchingCondition" next = "not-understood" } error { type = "NoMatchingError" next = "not-understood" } } action { id = "to-support" next = "transfer" update_contact_target_queue { queue_id = "queue:support" } error { type = "NoMatchingError" next = "not-understood" } } action { id = "to-priority" next = "transfer" update_contact_target_queue { queue_id = "queue:priority" } error { type = "NoMatchingError" next = "not-understood" } } action { id = "transfer" next = "hang-up" transfer_contact_to_queue {} error { type = "QueueAtCapacity" next = "busy" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "busy" next = "hang-up" message_participant { text = "All of our agents are busy. Please call back later." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "not-understood" next = "hang-up" message_participant { text = "Sorry, we did not get that. Goodbye." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## Collect digits and keep them With `store_input = "True"` the same action stores what the caller types in `$.StoredCustomerInput` instead of branching on it. That form needs `input_validation`, takes no conditions, and refuses the `NoMatchingCondition` and `InputTimeLimitExceeded` branches. `update_contact_attributes` then copies the digits into a contact attribute the agent's screen can show. `collect-input.tf`: ```hcl # Collect digits and keep them. With StoreInput "True", Connect stores the # digits in $.StoredCustomerInput instead of branching on them: the action # takes InputValidation, no conditions, and no NoMatchingCondition or # InputTimeLimitExceeded branch (the conditional-shape lint rule holds all # three). resource "flowascode_contact_flow" "collect_input" { instance_id = var.connect_instance_id name = "collect-input" type = "CONTACT_FLOW" refs = { "queue:support" = aws_connect_queue.support.arn } action { id = "ask-account" next = "keep-account" get_participant_input { text = "Please enter your eight digit account number." input_time_limit_seconds = 8 store_input = "True" input_validation = { custom_validation = { maximum_length = 8 } } } error { type = "NoMatchingError" next = "to-support" } } action { id = "keep-account" next = "to-support" update_contact_attributes { attributes = { accountNumber = "$.StoredCustomerInput" } target_contact = "Current" } error { type = "NoMatchingError" next = "to-support" } } action { id = "to-support" next = "transfer" update_contact_target_queue { queue_id = "queue:support" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "transfer" next = "hang-up" transfer_contact_to_queue {} error { type = "QueueAtCapacity" next = "hang-up" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## Route on a Lambda lookup Invoke a function with the caller's number, then branch on one key of what it returned. A `STRING_MAP` response lands in `$.External`, and `compare` takes any operator on any value; unmatched callers take `NoMatchingCondition`. The function must be associated with the instance (`aws_connect_lambda_function_association` in `supporting.tf`) or Connect cannot invoke it. `lambda-routing.tf`: ```hcl # Look the caller up with a Lambda function, then route on what it returned. # A STRING_MAP response lands in $.External, and Compare branches on one key. resource "flowascode_contact_flow" "lambda_routing" { instance_id = var.connect_instance_id name = "lambda-routing" type = "CONTACT_FLOW" refs = { "lambda:customer-lookup" = aws_lambda_function.customer_lookup.arn "queue:priority" = aws_connect_queue.priority.arn "queue:support" = aws_connect_queue.support.arn } action { id = "look-up" next = "check-tier" invoke_lambda_function { lambda_function_arn = "lambda:customer-lookup" invocation_time_limit_seconds = 8 invocation_type = "SYNCHRONOUS" lambda_invocation_attributes = { phone = "$.CustomerEndpoint.Address" } response_validation = { response_type = "STRING_MAP" } } error { type = "NoMatchingError" next = "to-support" } } action { id = "check-tier" next = "to-support" compare { comparison_value = "$.External.tier" } condition { operator = "Equals" operands = ["priority"] next = "to-priority" } error { type = "NoMatchingCondition" next = "to-support" } } action { id = "to-priority" next = "transfer" update_contact_target_queue { queue_id = "queue:priority" } error { type = "NoMatchingError" next = "to-support" } } action { id = "to-support" next = "transfer" update_contact_target_queue { queue_id = "queue:support" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "transfer" next = "hang-up" transfer_contact_to_queue {} error { type = "QueueAtCapacity" next = "hang-up" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## Offer a callback when the queue is full The transfer's `QueueAtCapacity` branch leads to a callback instead of a busy message. `update_contact_callback_number` has no catch-all but two named errors, both required; `create_callback_contact` queues the callback with its delays and attempts. `callback-when-busy.tf`: ```hcl # Offer a callback when the queue is full: the QueueAtCapacity branch sets the # callback number to the number the caller dialed from and queues a callback. resource "flowascode_contact_flow" "callback_when_busy" { instance_id = var.connect_instance_id name = "callback-when-busy" type = "CONTACT_FLOW" refs = { "queue:support" = aws_connect_queue.support.arn } action { id = "set-queue" next = "transfer" update_contact_target_queue { queue_id = "queue:support" } error { type = "NoMatchingError" next = "sorry" } } action { id = "transfer" next = "hang-up" transfer_contact_to_queue {} error { type = "QueueAtCapacity" next = "set-callback-number" } error { type = "NoMatchingError" next = "sorry" } } action { id = "set-callback-number" next = "queue-callback" update_contact_callback_number { callback_number = "$.CustomerEndpoint.Address" } error { type = "InvalidCallbackNumber" next = "sorry" } error { type = "CallbackNumberNotDialable" next = "sorry" } } action { id = "queue-callback" next = "confirm" create_callback_contact { queue_id = "queue:support" initial_call_delay_seconds = 5 maximum_connection_attempts = 3 retry_delay_seconds = 600 } error { type = "NoMatchingError" next = "sorry" } } action { id = "confirm" next = "hang-up" message_participant { text = "All of our agents are busy. We will call you back at this number." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "sorry" next = "hang-up" message_participant { text = "Sorry, we cannot take your call right now. Please try again later." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## Send a share of callers down a new path `distribute_by_percentage` draws a number from 1 to 100. Each condition takes the callers below its bound, in order, and `NoMatchingCondition` takes the rest: here 20% hear the new greeting. Use it to roll out a change to part of your traffic before all of it. `percentage-split.tf`: ```hcl # Send a share of callers down a new path. DistributeByPercentage draws a # number from 1 to 100; each condition takes the callers below its bound, in # order, and NoMatchingCondition takes the rest. Here: 20% new, 80% current. resource "flowascode_contact_flow" "percentage_split" { instance_id = var.connect_instance_id name = "percentage-split" type = "CONTACT_FLOW" action { id = "split" next = "current-greeting" distribute_by_percentage {} condition { operator = "NumberLessThan" operands = ["21"] next = "new-greeting" } error { type = "NoMatchingCondition" next = "current-greeting" } } action { id = "new-greeting" next = "hang-up" message_participant { text = "Welcome. You are hearing our new greeting." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "current-greeting" next = "hang-up" message_participant { text = "Welcome." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## A module several flows share A `flowascode_contact_flow_module` is written like a flow, without `type`, and ends in `end_flow_module_execution`. `greeting-module.tf`: ```hcl # A flow module several flows share. Flows invoke it through an alias, so a # change to the module reaches them only when the alias moves (see # module-release.tf), not the moment the module's content changes. resource "flowascode_contact_flow_module" "greeting" { instance_id = var.connect_instance_id name = "greeting" action { id = "greet" next = "done" message_participant { text = "Thanks for calling. Calls may be recorded for quality." } error { type = "NoMatchingError" next = "done" } } action { id = "done" end_flow_module_execution {} } } ``` ## Release the module through an alias Flows invoke the module through an alias, which points at a version. The version is keyed to the module's `content_hash`, so a content change replaces it; `create_before_destroy` makes the new version exist before the old one goes, and the alias moves to it in place in between. Connect refuses to delete a version an alias still points at. This is the shape `flow-cli emit --target flowascode` writes for a module a flow in the same set invokes by alias, and, with `--module-alias module:greeting@live`, for a module released on its own that nothing in the set invokes, with one difference: the emitted version and alias carry no `description`. A root that already holds this file by hand, descriptions and all, and switches to the emitted resources sees a plan that drops the descriptions (an in-place update, not a replacement); keep the hand-written resources if the descriptions matter, or accept the change. `outputs.tf` then carries `greeting_live_arn` for another root to bind. `module-release.tf`: ```hcl # A version is a snapshot of the module's current content, replaced whenever # that content changes. create_before_destroy makes the new version exist # before the old one goes, and the alias moves to it in place in between: # Connect refuses to delete a version an alias still points at. resource "flowascode_contact_flow_module_version" "greeting" { instance_id = var.connect_instance_id contact_flow_module_id = flowascode_contact_flow_module.greeting.contact_flow_module_id content_hash = flowascode_contact_flow_module.greeting.content_hash description = "Greeting as reviewed" lifecycle { create_before_destroy = true } } # The alias is what flows bind: its arn is the module ARN qualified by the # alias id, the only form Connect runs as the alias. resource "flowascode_contact_flow_module_alias" "greeting_live" { instance_id = var.connect_instance_id contact_flow_module_id = flowascode_contact_flow_module.greeting.contact_flow_module_id name = "live" contact_flow_module_version = flowascode_contact_flow_module_version.greeting.version description = "The version callers hear" } ``` ## Invoke the module from a flow The flow binds `module:greeting@live` to the alias's `arn`, which is the module ARN qualified by the alias id: the only form Connect runs as the alias. A bare module ARN would run the module's latest content, not the released version. `invoke-module.tf`: ```hcl # A flow that runs the shared greeting through its live alias, then continues. resource "flowascode_contact_flow" "invoke_module" { instance_id = var.connect_instance_id name = "invoke-module" type = "CONTACT_FLOW" refs = { "module:greeting@live" = flowascode_contact_flow_module_alias.greeting_live.arn "queue:support" = aws_connect_queue.support.arn } action { id = "greeting" next = "set-queue" invoke_flow_module { flow_module_id = "module:greeting@live" } error { type = "NoMatchingError" next = "set-queue" } } action { id = "set-queue" next = "transfer" update_contact_target_queue { queue_id = "queue:support" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "transfer" next = "hang-up" transfer_contact_to_queue {} error { type = "QueueAtCapacity" next = "hang-up" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` ## Use an action the tooling does not model A `generic` block carries any action type as its Flow language `Type` and its `Parameters` as `jsonencode(...)`, unchanged. The provider checks nothing inside it beyond what Connect checks, so prefer a typed block where one exists. Connect decides which error branches this action takes from its parameters: with `ContinueFlowExecution` "True" these three, with "False" only the catch-all. `unmodeled-action.tf`: ```hcl # An action type the tooling does not model yet still has a home: a generic # block carries its Type and its Parameters as JSON, unchanged. Nothing about it # is checked beyond what Connect checks, so prefer a typed block when one # exists; the catalog lists which do. Connect decides which error branches this # action takes from its parameters: with ContinueFlowExecution "True" it takes # these three, with "False" only the catch-all (sandbox, 2026-09-29). resource "flowascode_contact_flow" "unmodeled_action" { instance_id = var.connect_instance_id name = "unmodeled-action" type = "CONTACT_FLOW" action { id = "desk-phone" next = "hang-up" generic { type = "TransferParticipantToThirdParty" parameters = jsonencode({ ContinueFlowExecution = "True" ThirdPartyConnectionTimeLimitSeconds = "30" ThirdPartyPhoneNumber = "+15555550100" }) } error { type = "CallFailed" next = "hang-up" } error { type = "ConnectionTimeLimitExceeded" next = "hang-up" } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` --- # Agent skills: flow-as-code for AI coding agents An AI coding agent can write and change Amazon Connect flows with flow-as-code as well as a person can, provided it knows the rules Connect enforces and the shape the tools expect. This repository ships those rules as [Agent Skills](https://github.com/agentskills/agentskills): folders with a `SKILL.md` that an agent loads when a task needs it. | Skill | Use it to | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `author-connect-flows-hcl` | write or change `flowascode_contact_flow` and `flowascode_contact_flow_module` resources, fix a lint finding or a Connect refusal | | `promote-connect-flows` | lay out flows and environments, bind references per environment, write the promotion pipeline, release shared modules | | `adopt-connect-flows` | take existing flows over from the console or from `hashicorp/aws` without recreating them | | `flow-cli` | lint, generate, convert, emit, export and diff flows with `flow-cli` | `author-connect-flows-hcl` carries `references/actions.md`: every modeled action type with its HCL block, its attributes, the branches Connect requires and the flow types it is legal in. It is generated from the same action catalog lint and the provider read (`scripts/build-skill-reference.mjs`), and a test fails when it is out of date, so an agent is told exactly what a plan enforces. ## Claude Code The repository is a plugin marketplace. In Claude Code: ``` /plugin marketplace add flow-as-code/flow-as-code /plugin install flow-as-code@flow-as-code ``` or from a shell: ``` claude plugin marketplace add https://github.com/flow-as-code/flow-as-code claude plugin install flow-as-code@flow-as-code ``` The skills load on their own when a task matches their description, and can be run by name, for example `/flow-as-code:author-connect-flows-hcl`. The plugin carries no version number on purpose: an install follows the repository, so a correction to a skill reaches you when it merges rather than at the next release. ## Other agents The skills use only the portable Agent Skills fields (`name`, `description`, `license`, `compatibility`), so any agent that reads `SKILL.md` folders can use them. Copy `plugins/flow-as-code/skills/` from the repository into the directory your agent reads skills from. Each skill is also published on this site as plain markdown, linked from [llms.txt](https://flow-as-code.dev/llms.txt): - https://flow-as-code.dev/skills/author-connect-flows-hcl/SKILL.md - https://flow-as-code.dev/skills/author-connect-flows-hcl/references/actions.md - https://flow-as-code.dev/skills/promote-connect-flows/SKILL.md - https://flow-as-code.dev/skills/adopt-connect-flows/SKILL.md - https://flow-as-code.dev/skills/flow-cli/SKILL.md An agent without skill support can read [llms.txt](https://flow-as-code.dev/llms.txt) for a linked map of the site, or [llms-full.txt](https://flow-as-code.dev/llms-full.txt) for every document as one file. ## What the skills hold an agent to The same rules a reviewer would: a reference is a key bound in `refs`, never an ARN; every action wires the branches its type requires; flows are promoted from one module with a root module per environment, not copied; an existing flow is adopted, not recreated. `tests/skills.test.ts` checks, on every push, that each skill's frontmatter is valid, that every `flow-cli` command a skill names exists, that every complete flow a skill shows reads back through the HCL reader and lints clean, and that the action reference matches the catalog. --- # FlowDoc: the interchange format A FlowDoc is one JSON file per flow or module. It is Amazon Connect Flow language, extended with tokens and tool metadata that Connect ignores-by-removal at materialization time. ## Structure ```json { "flowdoc": "0.3", "kind": "flow | module", "name": "inbound-main", "displayName": "optional, the name Connect shows when it is not name", "description": "optional, as Connect shows it", "connectType": "CONTACT_FLOW | CUSTOMER_QUEUE | ... | MODULE", "content": { "Version": "2019-10-30", "StartAction": "...", "Actions": [ ... ] }, "layout": { "": { "x": 0, "y": 0 } }, "refs": [ { "token": "${cdref:queue:front-desk}", "type": "queue", "name": "front-desk" } ], "meta": { "generator": "core@0.3", "sourceHash": "sha256:...", "sourceKind": "ts | tf" } } ``` - `content` is verbatim Flow language except that every ARN position holds a `${cdref:type:name}` token. Ref types: queue, hours, lambda, lex, prompt, flow, module, view, and since 0.3 tasktemplate, casetemplate, casefield, assistant, phonenumber. The token grammar (`TOKEN_PATTERN` in `packages/core/src/refs.ts`) is `${cdref::}` with an optional `@` alias suffix on any type; the builder's `Refs.module(name, alias)` always emits one (`${cdref:module:recording-consent@prod}`), and `@flow-as-code/cdk` and `@flow-as-code/tf` read it as the module alias to publish and point at. A module ref without an alias is bound by `@flow-as-code/cdk` to its default alias `live` and by `@flow-as-code/tf` to an address you supply, like any other external reference. A `view` ref carries the view's version in the same slot (`${cdref:view:after-contact-work@1}`), because the console writes the version into the ARN; `Refs.view(name, version?)` builds it, and the version is optional. The five 0.3 types carry no alias (the grammar admits one on them as on every type, and only `module` and `view` give it a meaning; `@flow-as-code/cdk` binds the others by name alone): `tasktemplate` is `CreateTask.TaskTemplateId`, `casetemplate` is `CreateCase.CaseTemplateId`, `casefield` is a Cases field id, which stands as a whole map key of `CaseRequestFields` or a whole item of `CaseResponseFields` (docs/adr/0008-case-field-ids.md, the only token position that is not a field value), `assistant` is `CreateWisdomSession.WisdomAssistantArn` (an `arn:aws:wisdom:` ARN, not a Connect one) and `phonenumber` is `StartOutboundChatContact.SourceEndpoint.Address` (`arn:aws:connect:::phone-number/`, which nests under no instance). `Refs.tasktemplate(name)` and its four siblings build them. A `${cdref:user:...}` for `RouteContactToAgent` and a `${cdref:segment:...}` for `CheckSegmentMembership` were considered for 0.3 and not added: both Types were refused by the service on 2026-10-05 and their parameter keys are unknown until a console export (tasks/D00, "Pending"); if D09 models either with a resource id, that is a 0.4 bump, at the cost 0.3 paid (a migration case, every reader in both repositories, and a provider minor before the npm one). - `displayName` is the name Connect shows when that is not the slug in `name`, optional, 1 to 127 characters with a non-space character (the limits `CreateContactFlowModule` states: https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateContactFlowModule.html). `name` stays a slug, because it is the file name and the key other documents refer to; `displayName` is what a deploy names the Connect resource, through every path (the CDK construct, both Terraform emitters, the flowascode provider). Export writes it whenever the instance's name is not the slug it assigns, so adopting a console flow called "Main Line" keeps that name instead of renaming it `main-line` on the first deploy. Added 2026-09-29, before 0.2 had shipped in a release; absent means Connect's name is `name`. - `description` is the flow's description as Connect shows it, optional, at most 500 characters. It is authored data like `name`: the builder's `FlowConfig.description` carries it, `synth()` writes it, export reads it back from `DescribeContactFlow`, and codegen emits it, so the three views agree on one fact. Added in 0.2. - A module's `content` also carries a top-level `Settings` object; a flow's does not. Connect requires it and rejects `CreateContactFlowModule` without one (see the `Settings` comment in `packages/core/src/flowdoc.ts`); it may be empty, and `synth()` emits `{}` by default. Connect returns it inside the module's content, and export carries it into the FlowDoc (`conformance/export/module-settings`). The schema documents the property but does not yet enforce its presence on a module. - `layout` holds canvas positions keyed by action id. Synth assigns deterministic auto-layout when absent (the owned layered algorithm specified in `conformance/layout/README.md`, so a second implementation reaches the same integers); the studio persists user positions. Layout differences never affect flow behavior. - `layout` is the single source of truth for position. Flow language carries positions of its own in `content.Metadata.ActionMetadata..Position`, so FlowDoc deliberately does **not** author `content.Metadata`; materialization projects `layout` into it. Two stores of the same fact would drift. Recorded 2026-08-31 from the Flow language example, https://docs.aws.amazon.com/connect/latest/devguide/flow-language-example.html - `refs` is a derived index of every token in `content`; regenerated on save. The studio's ref pickers, the `@flow-as-code/cdk` scaffold, and the `no-unresolved-token` lint rule read it; materialization and the emitters walk `content` directly. - `meta.generator` names the tool that wrote the file (`core@0.3` from `synth()`, `cli@` from `flow-cli synth` and the studio bridge). `meta.sourceHash` is `sha256:` of the paired companion file's bytes, and `meta.sourceKind` (`ts` or `tf`, added in 0.2) says which companion that is: `.flow.ts` or `.flow.tf`. Absent means `ts`, the only companion before 0.2. - The two stamps deliberately count different things and neither is written by hand. `core@0.3` is the FlowDoc **format** version: `synth()` and `export()` are pure functions of the format, their output is byte-identical across `@flow-as-code/core` releases, and the conformance goldens under `conformance/export/` hold those bytes, so a package version there would churn every golden at every release for no provenance gained. `cli@` is the **package** version of `@flow-as-code/cli`, read from its manifest at runtime (`packages/cli/src/version.ts`), because flow-cli is the distributed tool and the question a committed document has to answer is which build wrote it. Do not read a package version out of `core@0.3`, and do not read a format version out of `cli@`; the format version is the top-level `flowdoc` key in every case. - Materialization (map or binder) replaces tokens, projects `layout` into `content.Metadata` so the flow lays out correctly in the Connect console, and drops the `layout`, `refs`, and `meta` keys themselves from deployable content. ## Invariants 1. Deterministic: same inputs, byte-identical FlowDoc (stable key order, stable action ordering). 2. Lossless for unknown blocks: any Action not modeled by the builder is preserved verbatim (GenericBlock) through studio edits and codegen. 3. `sourceHash` ties the FlowDoc to the builder file it was generated from, for the watch-mode dirty guard and the studio's conflict check. 4. A reference token occupies an entire field value, or, for a `casefield`, an entire map key or list item, and is never interpolated into a longer string. Connect requires the fields that hold references to be "either fully static or a single valid JSONPath identifier", so a partially substituted value is invalid. See conformance/flow-language/actions.md. (Restated to cover a key in 0.3, docs/adr/0008-case-field-ids.md.) 5. No more than 250 Actions per flow, and Identifiers are unique, at most 50 characters, and exclude the characters Connect reserves. Enforced by conformance/schema/flowdoc-0.3.schema.json. ## Versioning `flowdoc: "0.3"` since 2026-10-05; `0.2` was the format from 2026-09-11 and `0.1` from 2026-08-30. Every version bump ships a migration in `@flow-as-code/core` plus fixtures: `migrateFlowDoc` reads any supported version and returns the current one, `conformance/migrate//` holds an input at each older version and the exact bytes it becomes, and every reader (the CLI, the studio, the CDK construct, both Terraform emitters) migrates on the way in, so the rest of the code sees one version. 0.1 to 0.2 was a version bump alone: 0.2 added the `view` reference type, `meta.sourceKind`, and the optional `description`, none of which a 0.1 document could carry. 0.2 to 0.3 is the same: 0.3 added the `tasktemplate`, `casetemplate`, `casefield`, `assistant` and `phonenumber` reference types, all at once, because an older reader (npm 0.2.x, provider 0.1.x) refuses a document carrying a token it does not know, so each type added on its own would have been a release both repositories make in step (tasks/README.md, "Phase D", "Considered and not taken"). Adding a reference type is a version change by this precedent. The older schemas stay byte-identical, held by a hash in `packages/core/src/conformance.test.ts`, so a 0.1 or 0.2 file is still validated against the rules it was written to, and a per-type clause for a type modeled after 0.2 shipped goes into 0.3 only, encoding what the service enforces at create (owner decision 3 in tasks/README.md), so a 0.2 document the service accepted still validates after migration. ## Contract artifacts - `conformance/schema/flowdoc-0.3.schema.json` is the machine-readable form of this document; `flowdoc-0.2.schema.json` and `flowdoc-0.1.schema.json` are the frozen previous versions. A second implementation validates against the schema of the version a document names, as this one does. 0.3 differs from 0.2 in the token pattern, the `refs[].type` enum and the `description` strings alone; the per-type clauses the Phase D group tasks add are the rest of the difference. - `conformance/migrate/` holds the migration cases: an input at each older version and the bytes it becomes. - `conformance/flow-language/actions.md` records the Connect action types, parameter shapes, and reference-bearing fields this format wraps, with a doc URL per entry. - `conformance/flow-language/catalog.json` is the machine-readable twin of that document: every documented type, and for each modeled one its HCL block and attribute names, kinds, reference paths, restrictions, and transitions. A second implementation generates its schema from it; `packages/core/src/catalog.test.ts` holds the TypeScript tables to it. - `conformance/demo/appointment-line.flowdoc.json` is the canonical demo used by synth, round-trip, emitter goldens, the studio, and the hosted demo. --- # @flow-as-code/studio design A local-first visual editor over FlowDoc, with live bidirectional sync to typed builder code, and CDK or Terraform files as the export targets. This goes one step past both the Connect console (visual, but ARN-bound and instance-bound) and FlowLang (typed code and console round-trip, but proprietary and not IaC-native): visual editing whose output is IaC. ## Delivery - Primary: `flow-cli studio [dir]` starts a local Vite-built app plus a small file/watch server on 127.0.0.1. Works on a directory of FlowDocs and TS builder files. See "The bridge" below. - Later (backlog): the same webview packaged as a VS Code custom editor for `*.flowdoc.json`. - A hosted read-only demo (static build, the demo FlowDoc baked in) for marketing; no backend. Built by `npm run build:demo` and deployed at https://flow-as-code.dev/studio/ , see docs/05-hosted-demo.md. ## Canvas - React Flow (`@xyflow/react` 12). One node per Action; edges per transition, error edges styled distinctly. Deterministic auto-layout (`conformance/layout/README.md`) for docs without `layout`. - Palette groups mirror the Connect console taxonomy (Interact, Set, Branch, Integrate, Terminate) but only blocks `@flow-as-code/core` models are insertable; everything else renders as a read-only GenericBlock with raw JSON inspection. - Reference fields are typed pickers, not text: choosing a queue creates/uses `${cdref:queue:...}`. A refs sidebar lists every token in the doc with usage counts. Literal ARN entry is rejected in the UI (same rule as lint). - Inline lint: the lint engine runs in a worker on every change; findings badge nodes and list in a panel. The studio can never save a doc that fails `no-literal-arn` or `no-unresolved-token`. - A document whose `meta.generator` names something other than this toolchain's own stamps (`core@...`, `cli@...`, docs/01-flowdoc-spec.md) is badged with that generator, and the first edit waits for the user to say the next run may overwrite it. The document is not changed to record any of this; the generator's input is where such an edit belongs. ## Bidirectional sync (the core trick) - Studio edits write FlowDoc. The save also runs `codegen` to regenerate the paired `.flow.ts`, and stamps the document with `meta.sourceHash` of the source it just generated, so the pair is in sync for the watcher. - Opening a directory writes the missing half. A document with no `.flow.ts` beside it gets one before the server starts serving, through the same `codegen` path a canvas save uses, stamped the same way. Without it a directory of documents alone (an export from an instance, a file copied out of `conformance/`, a file a colleague sent) had nothing to edit, so the loop below could not start there until the user saved from the canvas once. Generating it on open is safe because codegen is deterministic: the bytes are the ones the first save would have written. An existing builder file is never touched, and a document that codegen refuses is named on the terminal and skipped so the rest of the directory still opens. The watcher is then told those exact bytes are the server's own, so its first scan cannot read the new pair as a divergence. - Edits to `.flow.ts` trigger `synth` (executed in a sandboxed child process) regenerating FlowDoc; the canvas hot-reloads without a page reload, keeping the viewport and the selection. The inspector's free-text fields commit on blur, so they hold a draft; a document that changes underneath one resets its draft. Uncommitted keystrokes lose to text that reached disk, because the alternative is committing text the document has never seen and writing it back over the file that changed. - The served directory needs no `node_modules`. A generated builder file imports `@flow-as-code/core` by package name, and the synth child falls back to `@flow-as-code/cli`'s own copy when the directory cannot resolve it (packages/cli/src/synth-resolve-hook.ts). A copy installed next to the file still wins. Without this the loop above only worked inside a project that already depended on `@flow-as-code/core`. - Dirty guard via `meta.sourceHash`: if both sides changed since last sync, the studio shows a diff and asks which side wins. No silent merges. That covers three states: the two files diverged on disk, a save whose builder file has moved underneath it, and a builder-file edit arriving while the canvas holds unsaved changes. All three get the same dialog, and saving is blocked until the user answers. So is editing: the dialog covers the whole shell and the reducer refuses a mutation while it is open, because the answer writes one of two frozen documents and an edit made behind it would be in neither. Keeping the canvas side is a write from the browser, so it runs the save gate first and the dialog names what the gate refused rather than leaving a dead button and a message in the header behind it. - Codegen is the hard part done well: one inline block construction per Action in document order, so the Identifier is the stable name and nothing is renamed or reordered on the way through; comments preserved via a `// @keep` convention; byte-stable output. Round-trip invariants are CI-tested (see CLAUDE.md). ## The bridge (`flow-cli studio`) The browser cannot write files, so the CLI does. One `node:http` server serves the built studio and a small JSON API; the studio talks to it through the DocStore seam and nothing else in the app touches persistence. - `GET /bridge/info`, `GET /bridge/docs`, `POST /bridge/docs`, `GET /bridge/docs/`, `PUT /bridge/docs/`, `POST /bridge/docs//resolve`, `GET /bridge/events?cursor=`, `POST /bridge/export`. - A document's companion is `.flow.ts` or `.flow.tf` (docs/06-terraform-provider.md, "Companions"), and protocol 2 says which: the document list, every payload and every conflict carry `sourceKind`. The toolbar shows the open document's companion as a badge, and a save regenerates that one, never the other. New flow (bridge only) takes a name, flow or module, and the companion, and `POST /bridge/docs` writes a minimal lint-clean document with it, refusing when the name is taken; switching a document's companion later is `flow-cli convert`. A `.flow.tf`'s `lint { disable = [...] }` arrives on the payload as `lintDisable` and is passed to lint, never for a hard rule. What reading a `.flow.tf` noticed without refusing it (a `refs` key no action uses, a resource address read as its key) arrives on `synced` and shows as a notice. - `POST /bridge/export` takes a target and a relative-path-to-content map the studio has already emitted, and writes it under the served directory. Every path is checked twice, against the protocol's path rule and then by resolving it and requiring the result to be inside that directory. It pairs nothing, publishes no event, and overwrites only what it writes, because emitted terraform and CDK files are output rather than documents. - The event stream is a long poll carrying the watch engine's `synced`, `conflict`, and `error` events, enriched with the document (or both sides of the conflict). An `error` is a builder file that did not become a FlowDoc, so the canvas and that file have stopped agreeing: the studio shows the first line of it as a toast and keeps an out-of-sync badge naming the file until that document syncs again. The terminal running `flow-cli studio` prints the whole message plus the stack frames that point at files the user wrote; frames inside `node_modules` and node's own internals are dropped, because a transform failure's stack is ten frames of esbuild and stream internals under a line that already names the file, line and column. The badge is width-capped, so its visible label stops at the file name and the sentence that says what is wrong is its `aria-label` and its tooltip, with the message cut to its first line and the absolute path inside it reduced to that same file name. Undoing a broken edit clears it: a builder file restored to the bytes of the last successful sync emits `synced` even though nothing was rewritten, because otherwise the badge outlives the state it reports. No websocket library and no EventSource: a long poll needs nothing but fetch and behaves the same in a browser and in a test. - The protocol is one file, `packages/cli/src/bridge/protocol.ts`, copied byte for byte into the studio because `@flow-as-code/cli` depends on the studio for its assets and the reverse import would be a cycle. A test fails if the copies differ. - The server injects its own description into the served `index.html`, so the studio knows it has a bridge without probing for one and a static build makes no request at all. - Local-first is enforced by shape: 127.0.0.1 only and not configurable, the `Host` header checked against localhost (loopback alone does not stop DNS rebinding), a per-run session token carried in the printed URL that every bridge API request must present (assets need none, so the page can boot), refusal of any request carrying a foreign `Origin` or a `Sec-Fetch-Site` other than `same-origin` or `none`, no CORS headers, documents addressed by slug rather than path, and every asset path checked to be inside the one served directory. SECURITY.md records the threat model. - Writes go through the CLI, which is the only thing that touches disk, and both processes validate: the studio runs its save gate before the request and the server validates against the packaged FlowDoc schema again. ## Export targets (in-app buttons and CLI parity) - CDK: writes/updates a stack file using `@flow-as-code/cdk` (FlowSet + TokenBinder scaffold with TODO bindings for each ref type). - Terraform: invokes `@flow-as-code/tf` (see docs/03-tf-emitter.md). - Terraform (flowascode provider): invokes `@flow-as-code/hcl`'s `emitFlowascode`, one `flowascode_contact_flow` resource per document with its actions as blocks (docs/06-terraform-provider.md). - Raw: materialized Flow JSON against a chosen resource map (for console import via API, debugging). All four run over the whole document set, which is what the IaC targets mean: one terraform configuration, one binder covering every reference type in the set. Parity with the CLI is enforced by construction and then tested. The generators are shared modules rather than reimplementations (`@flow-as-code/cdk/scaffold` behind both the CLI's `emit --target cdk` and the studio's button, `@flow-as-code/tf/emit`, `@flow-as-code/hcl`'s `emitFlowascode`, `@flow-as-code/core`'s `materializeWithMap`), and a test in `@flow-as-code/studio` runs the built CLI as a subprocess and compares the bytes. Both Terraform targets need an address per reference, so they share one map editor: every reference in the set with its type and name, an address field each suggesting the resource type that reference actually needs, and a count of what is still unmapped. A literal ARN is refused inline, the same rule the ref pickers apply to parameters. What counts as unmapped is read back from the emitter's own TODO output rather than recomputed, and from the emitter of the target being exported, since the two resolve different references from the set itself (`@flow-as-code/hcl` also resolves a module invoked without an alias); a reference the set resolves itself needs no entry. An unmapped reference is a `TODO_MISSING_ADDRESS_*` placeholder that fails `validate` under `tf`, and `null` under a TODO comment that the provider refuses at plan time under `flowascode`. The raw target's resource map is the opposite case and takes ARNs, since its output is deployable Flow language; an incomplete map refuses the export and lists every missing token. Exporting is a write path, so it runs the same save gate as a save: a document failing a hard lint rule or the schema cannot be exported by any target, and the check lives in the export functions rather than in the buttons. Delivery has two ends and one code path. Under `flow-cli studio` the emitted file map is POSTed to the bridge, which writes it (the CLI is the only thing that touches disk, and it re-checks every path); with no bridge the browser downloads the files, folding each directory into the file name because a browser cannot create one. The bundle is built once, before either end sees it. ## Explicitly out of scope for v1 Multi-user editing, cloud storage, Connect console import-file parsing (not Flow language), TF-to-FlowDoc reverse parsing, telemetry. --- # @flow-as-code/tf: Terraform/OpenTofu emitter One-way: FlowDoc in, HCL out. This emitter's output is rendered content inside `aws_connect_contact_flow` resources and is not read back. The provider-native shape, which is read back, is `@flow-as-code/hcl`'s: a `.flow.tf` companion per document, and `emit --target flowascode` for a set (docs/06-terraform-provider.md). `examples/promote-across-environments/` is this emitter run twice over one document with two address maps, which is what the per-environment story looks like end to end. ## Output shape (per flow set) - `flows/.flow.tftpl`: the materializable content with each `${cdref:type:name}` rewritten as a templatefile variable `${queue_front_desk_arn}`. - `flows.tf`: `aws_connect_contact_flow` / `aws_connect_contact_flow_module` resources with `content = templatefile("${path.module}/flows/.flow.tftpl", local.flow_refs)`. Corrected 2026-08-31: the path must be `${path.module}`-relative or the output breaks the moment it is consumed as a Terraform module, since `templatefile` resolves a bare relative path against the calling module's directory. - `flow_refs.tf`: a `locals { flow_refs = { queue_front_desk_arn = aws_connect_queue.front_desk.arn, ... } }` scaffold. Emitter fills entries it can infer from a provided address map (`--address-map refs.tfmap.json`, mapping ref name to terraform address); everything else is emitted with a `# TODO` and a placeholder so `terraform validate` fails loudly rather than applying garbage. A map key no reference in the set uses is reported (`unusedMapKeys`; a warning on the CLI, an error under `--strict`) rather than dropped in silence. - Module versions/aliases: emit `awscc_connect_contact_flow_module_version` and `awscc_connect_contact_flow_module_alias` resources (hashicorp/awscc v1.74.0+, 2026-03-04) with a comment noting full-flow versioning is Campaign-only per current AWS docs. One alias per alias the set's flows invoke the module through; a module nothing in the set references gets a version and no alias unless `moduleAliases` (`--module-alias module:name@alias`) declares the aliases it publishes, written in the same shape (`conformance/emit-tf/module-release`). - `variables.tf`: one `connect_instance_id` variable the resources reference, emitted unless `instanceIdExpression` supplies the instance from an address you already manage (`conformance/emit-tf/hostile-text` covers the omission). - No `outputs.tf`. The flowascode target writes one (an ARN and a document hash per document, docs/06) from the provider's `flowdoc` attribute; `hashicorp/aws` has no attribute holding the document, only the rendered content with each environment's ARNs in it, so the only hash this target could offer is of the emitted template file, which is emitter output rather than the document and would not match the flowascode target's for the same FlowDoc. A flat-target pipeline has the emitted files in version control to compare (`tasks/C13-emit-per-flow-outputs.md`). - Provider blocks are NOT emitted; the user owns providers/backends. `versions.tf.example` documents the required providers (aws >= 5.0; awscc >= 1.74 only when the set contains a module, since only the module version and alias resources need it) and the `>= 1.7.0` core constraint; the `.example` suffix keeps it out of `terraform init`, and the header says to copy what is needed. ## Rules - Deterministic file output; stable variable naming (`__arn`). - Never emit a literal ARN. Never emit provider credentials or backend config. - OpenTofu and Terraform parity: emit nothing Terraform 1.8+/OpenTofu 1.7+ do not both support. The OpenTofu end of that promise is one constant, `CORE_VERSION_FLOOR` in `packages/tf/src/emit.ts`: it is what every `versions.tf.example` declares as `required_version`, and CI's emit-tf job runs the gated suite against it and against a current release, asserted by an ungated test so the matrix cannot stop covering the floor. ## Corrections found by running the emitter (2026-08-31) A single shared `local.flow_refs` does not survive a set that emits a module another document in the set invokes. The module alias address lives in the map, and the resources rendered from the map depend on it, so both Terraform and OpenTofu reject the cycle: ``` Error: Cycle: awscc_connect_contact_flow_module_version..., local.flow_refs (expand), ... ``` Addresses that point at resources the emitter itself renders are therefore emitted per document, as `merge(local.flow_refs, { ... })`, and only user-supplied addresses live in the shared local. `local.flow_refs` remains the scaffold the user edits. Terraform identifiers may not begin with a digit, but FlowDoc names may (`SLUG_PATTERN` permits it). A name like `2fa-line` produced `resource "aws_connect_contact_flow" "2fa_line"`, which OpenTofu rejects with "Invalid resource name". Such names are prefixed with an underscore. Collision-free, because no slug contains one. ## How the gated tests check a template render (2026-09-04) The reference-implementation check used to drive `tofu console` through a pipe. On the first CI run that ever reached it, every call hung for its full timeout. `opentofu/setup-opentofu@v1` installs a wrapper by default (input `tofu_wrapper`, default true) that runs the binary through `@actions/exec` without passing `options.input`, so the child's stdin is a pipe nothing ever writes to or closes; the console evaluates a piped script only at EOF, and EOF never arrived. The version was not the variable: with that stdio the console hangs on OpenTofu 1.7.0 and 1.12.6 alike, and with a pipe that closes both answer in about 30ms. `tofuEvaluateString` in `packages/tf/src/__fixtures__/tofu.ts` replaces it. It writes the expression into an `output` block, runs `tofu apply -auto-approve -input=false` (the directory has no providers, so the apply is offline, needs no credentials, and writes local state into a temp dir) and reads the value back with `tofu output -raw`. No REPL, nothing reading stdin, identical on both versions. The value still crosses the CLI boundary base64-encoded: that is what makes it byte-exact for awkward content, which `conformance/emit-tf/hostile-text` exists to prove. --- # Hosted read-only demo A static build of `@flow-as-code/studio` with `conformance/demo/appointment-line.flowdoc.json` baked in. It runs from any static host, edits only in memory, makes no network request after its own assets load, and names no npm package. Task: tasks/A14-hosted-demo.md. It is deployed and serving. https://flow-as-code.dev/ is the landing page and https://flow-as-code.dev/studio/ is this artifact, both from GitHub Pages, both over HTTPS with HTTP redirected to it. A visitor gets the demo appointment line on the canvas with no account and no sign-up: eleven blocks, every one modeled (the `UpdateFlowLoggingBehavior` block was the generic exemplar until the builder gained the type on 2026-09-11; an unmodeled action still renders as a generic block, which `conformance/roundtrip/unknown-actions` and the studio's canvas tests hold), a working inspector, refs sidebar and lint panel, and "Export as..." rendering the Terraform and CDK output into the page. There is no Save and no FlowDoc download, because the store refuses writes. How that deploy is wired, and what any other host would need, are under Deploy below. ## Build ``` npm run build:demo ``` Runs `vite build --mode demo` in `packages/studio` and writes `packages/studio/dist-demo/` (gitignored, about 720 kB uncompressed: `index.html` plus eight hashed assets under `assets/`). `npm run build` at the root produces it too, after the regular studio build. The root build order is `tsc -b`, `vite build`, then `vite build --mode demo`, so the demo is always built from the same `@flow-as-code/core` output the CLI ships. The regular build in `dist/` and the demo build in `dist-demo/` come from one `vite.config.ts` and one source tree. What differs is decided by the mode, in three places. ### The app knows it is the demo `src/demoBuild.ts` exports `DEMO_BUILD = import.meta.env.MODE === "demo"`. Vite inlines the mode, so the flag is a compile-time constant and the regular build carries the other branch as dead code. `defaultStore` (`src/state/studio.tsx`) boots the demo through `ReadOnlyStore`, a wrapper whose `write` always refuses and whose `readOnly` flag the toolbar reads: Save, Open file, Open folder, and the FlowDoc download are hidden, a "Read-only demo" badge is shown, and dirty state is labelled "edited in this tab". The canvas, inspector, lint panel, and reference pickers work unchanged because the document in memory is the app's own copy. "Export as..." stays, with `PreviewExportSink` in place of the download and bridge sinks: the same file bundle every other sink receives is rendered as text inside the dialog, and the dialog says so. Nothing is downloaded or written. There is no persistence of any kind, deliberately. The store is `MemoryStore` behind the wrapper, and the studio does not touch `localStorage`, `sessionStorage`, or IndexedDB anywhere (`grep -rn localStorage packages/studio/src` is empty). A reload restores the pristine demo. ### Two modules are swapped for stubs `vite.config.ts` registers a small plugin, `flow-studio:demo-stubs`, only when the mode is `demo`, in the app bundle and (through `worker.plugins`) in the lint worker. It matches on the fully resolved module path, not on the import specifier, so the swap holds however the module is reached. | Real module | Stub | Why | | ----------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/core/src/package-names.ts` (or its `dist/` build) | `packages/studio/src/demo/package-names.ts` | The artifact must not name a package anyone can install (tasks/A14-hosted-demo.md). The stub keeps the same export surface (`PACKAGE_SCOPE`, `PACKAGE_NAMES`) with the RFC 2606 placeholder scope `@example`, so a CDK preview in the demo reads `import { FlowSet } from "@example/cdk"`. | | `packages/studio/src/store/bridgeStore.ts` | `packages/studio/src/demo/bridgeStore.ts` | `BridgeStore` is the studio's only network client. It is unreachable in a static build (no `flow-cli studio` process injects a bridge description into the page), but unreachable code still carries `fetch(` and the long-poll loop, and the offline scan below would see it. The stub exports the same names (`BridgeStore`, `BridgeConflictError`, `servedByBridge`, `createBridgeStore`, `fetchBridgeInfo`), never finds a bridge, and cannot be constructed. | `packages/core/src/package-names.ts` is the one indirection point for package names. Codegen, the `@flow-as-code/cdk` scaffold, the `@flow-as-code/tf` banner, and the CLI's studio asset lookup import their specifiers from it, so the 2026-09-03 rename to the `@flow-as-code` scope was one edit there plus the package manifests, and the demo build overrides it in one place. `tests/demoStubs.test.ts` compares the export surface of each stub with the module it replaces, because Vite resolves the swap by path with no type check across it; a value export missing from a stub would show up as a blank deployed page and nowhere else. The demo also sets `build.modulePreload.polyfill` to false. The polyfill is the only other `fetch(` in the bundle; without it, browsers that lack native `` do not preload. ### Relative paths and a Content-Security-Policy `base` is `./` in demo mode, so `index.html` references every asset as `./assets/...` and the artifact serves from a domain root or from any subpath (the verification below serves it under `/demo/`). The plugin prepends one `` tag to `index.html`, because a static host may not let you set response headers: ``` default-src 'self'; connect-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none' ``` `connect-src 'none'` is the runtime enforcement: a request the tests missed still fails in the browser. `'unsafe-inline'` for styles is what React Flow's inline `style` attributes (node positions, the viewport transform) need. `'unsafe-eval'` is there because ajv compiles the FlowDoc schema with `new Function`; the first runtime check of this build found a blank page and an `EvalError` in the console without it. It widens what the page's own scripts may do, not where scripts may come from or connect to. The reason the schema is not precompiled instead is recorded in the chunking comment at the top of `vite.config.ts`. ### A narrow viewport gets a notice, not a broken layout The demo is what an announcement points at, and announcement traffic skews mobile. The studio is a three-column editor and it is not going to work on a phone, so below a breakpoint the first thing a visitor sees is a notice saying so (`src/components/NarrowNotice.tsx`). The breakpoint is measured, not chosen from a list of device widths. The palette column (`w-52`) and the inspector (`w-72`) do not shrink, so with their borders they take a fixed 496px whatever the viewport is, and the canvas gets the rest. Measured in Chrome on the built artifact: | viewport | canvas | | -------- | ------ | | 1096px | 600px | | 896px | 400px | | 764px | 268px | | 696px | 200px | | 636px | 140px | | 556px | 60px | | 496px | 0px | A block node is `w-45`, 180px. So 676px is where the canvas can no longer show one whole block, and `NARROW_MAX_PX` is 700: that number with a little air around the node. Above it the layout is tight but honest; below it there is nothing to look at. The notice is not a wall. Two controls close it for the rest of the visit, so the canvas stays reachable, and in this build a third link goes back to the site root as the relative href `../`, which resolves to the root wherever the tree is mounted, subpath included. It cannot be an absolute URL: `tests/demo-bundle.test.ts` refuses one anywhere in the bundle. It keys off the viewport width, not the input device, so a small window on a laptop sees it too and widening the window takes it away with no reload. While it is up, `overflow: hidden` goes on both `` and ``: at a 390px viewport the document scrolls sideways to the shell's 496px, and measured in Chrome `scrollTo(500, 0)` still moved to `scrollX` 106 with only one of the two locked. #### The local build says something different The same component ships in `dist/`, which `flow-cli studio` serves over its bridge against a person's own `.flow.ts` files, and two of the sentences above are false there. That studio writes to disk, so "read-only demo" is a lie, and nothing sits above a bridge path, so `../` is not the project site. The width advice is true wherever the shell is squeezed, so it is unconditional; the read-only sentence and the site link are gated on `DEMO_BUILD`. `DEMO_BUILD` and not `store.readOnly`, because both gated parts are claims about the artifact rather than about the open document: that this bundle has nowhere to save, which is exactly when `defaultStore` wraps the demo store read-only, and that it was built with `base: "./"` and copied to `/studio/` by `scripts/build-site.mjs`, which is the only reason `../` means anything. A read-only store answers the first and cannot answer the second at all. Being compile-time, the gate also keeps the sentence and the anchor out of the local bundle entirely. In their place a local visitor is told to widen the window past `NARROW_MAX_PX` and that their flow files are still there, and is offered no link away. #### It is modal, and says so The overlay is opaque, covers the viewport, and pins the page under it, so a sighted visitor cannot reach the canvas until it is closed. `role="dialog"` alone left everything reading the accessibility tree with a different deal: Tab went straight into the canvas the notice had just called unusable, and Escape did nothing. It now carries `aria-modal="true"`, takes focus when it appears, cycles Tab inside itself (pulling focus back if something outside took it), returns focus to the control that had it, and closes on Escape the way the two buttons do. ## Deploy ### Any static host Copy the contents of `packages/studio/dist-demo/` to any static host: an S3 bucket behind CloudFront, GitHub Pages, Netlify, Cloudflare Pages, or a directory under nginx. Requirements on the host: - Serve `index.html` for the directory and the files under `assets/` with their own paths. No rewrite rules are needed; the app has no client-side routes. - Serve `.js` as `text/javascript` and `.css` as `text/css`. Module scripts and workers are refused by browsers under a wrong type. - Any path prefix works. Nothing in the artifact assumes the root. - Set no response headers that add origins. If the host adds its own CSP header, the stricter of the two applies, and the header must keep `'unsafe-eval'` in `script-src`. - Do not attach analytics, a tag manager, or an injected script at the host layer. The whole point of the artifact is that it loads nothing else, and the meta CSP will block such injections anyway. The hashed asset names change on every build that changes content, so caches can hold `assets/` for as long as they like; `index.html` should be revalidated. ### The project's own deploy: GitHub Pages The published site is one tree with a hand-authored landing page at the root and this artifact under `/studio/`: ``` dist-site/ index.html site/index.html: one file, inline CSS, no external font or script CNAME flow-as-code.dev favicon.ico the mark at 16, 32 and 48, which is the icon of every page here favicon.svg the same mark, following the reader's theme apple-touch-icon.png 180x180, opaque, for an iOS home screen mark.svg the mark alone logo.svg the lockup, mark and wordmark, for a light background logo-dark.svg the same, for a dark one logo.png the banner the READMEs put at the top, which npm and GitHub can only reference by absolute URL, so it is served from here og.png 1200x630, the card a link preview shows studio/ a byte copy of packages/studio/dist-demo/ ``` The icon files sit at the ROOT of the tree rather than in an assets directory, and that is load bearing rather than tidy. A browser asks the ORIGIN for `/favicon.ico` when a page declares no icon, and the studio page declares none: `packages/studio/tests/demo-bundle.test.ts` forbids any `href` in that HTML that is not `./assets/...`, so it cannot carry a `` at all. A file at the root gives the landing page, every docs page and the studio the same icon without editing one guarded byte of the artifact. The pages that can carry the tags carry them, relative like every other link on this site. `npm run build:site` (scripts/build-site.mjs) assembles it. The script copies and does nothing else. The artifact is already relocatable, so nothing under `studio/` is rewritten on the way in, and `tests/site.test.ts` compares every copied file with `dist-demo/` byte for byte so that a future rewrite cannot slip past the guards in `demo-bundle.test.ts`. The script refuses to assemble when `dist-demo/` is missing or incomplete, or when it is stale, and its message names `npm run build`. Stale is a timestamp comparison: the newest mtime under `dist-demo/` against the newest mtime across the inputs of `vite build --mode demo`, which the script lists as `SOURCE_PATHS`: the studio's `src/`, `index.html`, `vite.config.ts` and `package.json`; the `src/` of core, tf, cdk and hcl, which the demo bundles; and `conformance/demo/` and `conformance/schema/`, the demo flow and the FlowDoc schemas the studio imports into the bundle. When any file there is newer than every file in `dist-demo/`, the message names it. `tests/site.test.ts` derives that set of inputs by following the imports from the studio's entry and fails when one of them lies outside every `SOURCE_PATHS` entry, so an input added later (the vendored showcase, tasks/C08) has to join the list in the same change as the import. It refuses on the logo too, and there the check is content rather than timestamps: every generated file has to still carry the path data in `design/mark.mjs` and `design/wordmark.mjs`, each raster has to be the size its reader demands, and `favicon.ico` has to hold a 16 and a 32. A checkout gives every file the same mtime, so a date comparison would be flaky there and would still miss a hand edit. `design/README.md` is how the files are regenerated and why they are what they are. That is the full build on purpose: `npm run build:demo` alone fails on a clean checkout, because the studio resolves `@flow-as-code/core` to `packages/core/dist`. `.github/workflows/pages.yml` runs `npm ci`, `npm run build`, `npm run build:site`, then `actions/configure-pages`, `actions/upload-pages-artifact` on `dist-site`, and `actions/deploy-pages`, on every push to main and on `workflow_dispatch`. Permissions are `contents: read`, `pages: write`, `id-token: write`. The concurrency group is `pages` with `cancel-in-progress: false`, because cancelling a deploy in flight can leave the Pages environment holding a half-applied deployment and the next push is seconds behind anyway. `npm run build:site` also runs in the CI build job, which is where the assembler and its staleness gate are exercised on a pull request, before a merge can publish a stale canvas. The job is gated on `github.event.repository.visibility == 'public'`. GitHub Pages does not serve a private repository on a free plan, so the gate was written to SKIP rather than fail while the repository was private, and to start deploying by itself the moment the repository was flipped public. Both triggers carry the repository object in their payload, so this reads the live visibility rather than a variable someone has to remember to set. The repository is public now and the gate passes: the deploy of 15f8886 is run 34369711711, success. The landing page links to the studio as `./studio/`, never `/studio/`, so the same tree serves correctly from a subpath as well as from the apex. That is not decoration: it is what a repository-scoped Pages URL (`https://.github.io//`) needs if the custom domain is ever dropped, and serving the tree under a prefix is part of the runtime check below. Runtime check of the assembled tree, done on 2026-09-04 in Chrome against `dist-site/` served by a small `node:http` server that logs every request, first from the root and then from `/some/prefix/`. Both times: the landing page rendered and its button navigated to the studio; the canvas showed the eleven `appointment-line` nodes with the "Read-only demo" badge, "Export as..." as the only toolbar action, no Save and no file download, and a clean lint panel; the export dialog generated five Terraform files and one CDK file into the page, the CDK preview importing from `@example/cdk`; the console was empty; and every request the page made was to its own origin and path, ten of them under the prefix with `assets/` correctly resolved beneath it. The network list held two further entries, both the automation extension's own `chrome-extension://` script, which is the browser, not the page. ### How the domain and the Pages site are configured Everything a commit can automate is automated. Four things were repository or DNS settings and are not in any commit. All four are done; this is the record, because the next person to move the domain needs it. 1. The repository is public. Pages cannot serve a private repository on the free plan, and the workflow's visibility gate stays skipped until it is. 2. The Pages source is GitHub Actions, set under Settings, Pages, Build and deployment, Source. With the source left at a branch, `actions/configure-pages` fails the run rather than deploying. `gh api repos/flow-as-code/flow-as-code/pages` reports this as `"build_type": "workflow"`. 3. The apex points at Pages and `flow-as-code.dev` is the custom domain. Four A records for `flow-as-code.dev` to 185.199.108.153, 185.199.109.153, 185.199.110.153, and 185.199.111.153, and four AAAA records to 2606:50c0:8000::153, 2606:50c0:8001::153, 2606:50c0:8002::153, and 2606:50c0:8003::153, with `flow-as-code.dev` entered as the custom domain in Settings, Pages. https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site . `dig flow-as-code.dev A` and `AAAA` return exactly those eight addresses, and the Pages API reports `"cname": "flow-as-code.dev"` with the certificate approved. The repository setting is what matters here: that page states that when publishing from a custom Actions workflow "no `CNAME` file is created, and any existing `CNAME` file is ignored and is not required". `site/CNAME` is deployed anyway, because it costs nothing, it is what a branch-based publish would read, and it keeps the apex written down next to the page that assumes it. 4. Enforce HTTPS is on, in Settings, Pages, which the same page notes can take up to 24 hours to become available after the certificate issues. The Pages API reports `"https_enforced": true`, and `curl -I http://flow-as-code.dev/` answers 301 to `https://flow-as-code.dev/`. ### Runtime check on the live domain Done on 2026-09-09 in a browser against the deployed site, not a local server. The landing page served from https://flow-as-code.dev/ and its button reached https://flow-as-code.dev/studio/ ; the canvas showed the eleven `appointment-line` nodes with the "Read-only demo" badge, no Save and no FlowDoc download; the export preview rendered; the console was clean; and every network request was same-origin, eight assets plus the lint worker. `/favicon.ico` 404ed, because there was no icon at the root of the site yet and the studio ships no icon link of its own: `packages/studio/tests/demo-bundle.test.ts` forbids any `href` in its HTML that is not `./assets/...`, so the browser asks for the default path anyway. That request is the reason the icon files were put at the root of the tree rather than under an assets path; it is answered now. ## Verification Two proofs, in the order they should be run. The first is a test and runs in CI; the second needs a browser. ### Static: the artifact has no way to make a request ``` npm run build:demo npx vitest run --project @flow-as-code/studio tests/demo-bundle.test.ts tests/demo-boot.test.tsx tests/demoStubs.test.ts ``` `tests/demo-bundle.test.ts` reads every file in `dist-demo/` and asserts: - every `src` and `href` in `index.html` is a relative `./assets/` path and the HTML contains no absolute URL; - the CSP meta tag is present with `default-src 'self'`, `connect-src 'none'`, `script-src 'self'`, and `object-src 'none'`; - no file contains a network primitive: `fetch(`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `sendBeacon`, `importScripts(`, `serviceWorker`; - every absolute `http(s)://` string in every file is on the exact-match allow-list in `tests/offlineScan.ts`, and no CSS carries `url(http...)` or `@import` of a URL; - no file mentions a published package name or the scope, both read from core's `PACKAGE_NAMES` and `PACKAGE_SCOPE`, while the `@example` placeholder is present; - the demo flow (`appointment-line`) and its block types (`UpdateFlowLoggingBehavior` among them) are in the bundle, as is the "Read-only demo" badge text and the lint worker chunk. The allow-list cannot be XML namespaces only. Bundled libraries carry string constants that are URLs without ever loading them: JSON Schema `$id` and vocabulary identifiers that ajv resolves against its own bundled meta-schemas, the `$id` of the FlowDoc schema, React's error-decoder link, React Flow's attribution link, the rolldown and Tailwind banners, and the AWS and Terraform registry citations that `@flow-as-code/tf` writes as comments into the HCL it emits. Each entry is an exact string; a new URL under a familiar origin does not pass because its prefix is familiar. The same list judges the regular build in `tests/bundle-offline.test.ts`, so the two cannot drift. `tests/demo-boot.test.tsx` boots the built `dist-demo/assets/index-*.js` under happy-dom with `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, and `navigator.sendBeacon` replaced by spies that throw, then asserts the demo rendered (title, badge, the GenericBlock's type), that Save and the FlowDoc download are absent and "Export as..." is present, and that no spy was called. `tests/demoStubs.test.ts` holds the two stubs to the export surface of the modules they replace, and pins that the bridge stub never finds a bridge even when a bridge description is injected into the page. Without `dist-demo/`, `demo-bundle.test.ts` fails one test naming `npm run build:demo` and the rest skip. Each guard was mutation-tested when it landed: disabling the swap turned the primitive scan and the package-name check red; restoring the modulepreload polyfill turned the primitive scan red on `fetch(`; dropping an export from the bridge stub turned the surface comparison red; setting the stub scope to the real one turned the package-name checks red; removing `connect-src 'none'` and the relative base turned the CSP and relocatability checks red. `grep -ri @flow-as-code packages/studio/dist-demo` is empty after a build. The scope is matched with its leading `@` because the bare name is also the GitHub org and the schema `$id` host: the `$id` of the FlowDoc schema (`flow-as-code.dev`, from `conformance/schema`) is a schema identifier, not a package name, and is on the allow-list by exact match. ### Runtime: the browser makes no request 1. Serve `dist-demo/` from a local static server under a subpath, for example `python3 -m http.server` from the directory or a few lines of `node:http`. Do not use `vite preview` for this; its dev client is not the artifact. 2. Open the page in a browser with DevTools on the Network tab, "Disable cache" checked, and the console visible. 3. Confirm the canvas shows the eleven nodes of `appointment-line`, the "Read-only demo" badge, only "Export as..." in the toolbar, and a clean lint panel. 4. Click the `enable-logging` node. The inspector shows its logging behavior (`UpdateFlowLoggingBehavior`, Enabled or Disabled); until 2026-09-11 this was the demo's unmodeled block and showed raw JSON instead, and the 2026-09-01 check below was made against that form. 5. Open "Export as...", export Terraform, then CDK. The dialog reports "Generated N file(s) to this page" and shows each file as text; the CDK preview imports from `@example/cdk`. 6. Reload. The selection and the dialog are gone and the demo is pristine. 7. Read the Network tab: every entry is the page's own origin and path (`index.html`, the JS and CSS chunks, and `lint.worker-*.js`; a `favicon.ico` request from the browser itself is a 404 on the same origin). Read the console: no errors. A CSP violation would appear here as a blocked request, so an empty console after the interactions above is the proof. Done on 2026-09-01 with the artifact served under `http://127.0.0.1:4173/demo/` from a twenty-line `node:http` server that logs every request. The server log, the Network tab, and the console agreed: ten same-origin requests, nothing else, no errors. The one finding was the `'unsafe-eval'` requirement described above, which the static tests could not see and the first load did. ## What is not in this build - No save, no bridge, no file writes, no file open. The read-only store refuses `write` and the toolbar does not offer it. - No persistence. Edits live in the tab until reload. - No hosting requirements of its own. The artifact is host-agnostic; this project happens to deploy it to GitHub Pages, and any static host will do. --- # Terraform provider and the HCL view Phase B adds a third way to author a flow, HCL, and a Terraform provider that applies it. This page is the reader's map: what the provider is, what a `.flow.tf` looks like, how it pairs with a document, which versions work together, how to migrate from `hashicorp/aws`, and how the provider is published. The contract itself is `conformance/hcl/README.md`; the decisions are ADR-0006 and ADR-0007. To learn it by doing, start with [Your first flow with the Terraform provider](tutorials/01-first-flow.md). ## What it is `flow-as-code/flowascode` is a Terraform provider (Terraform 1.8 and later, OpenTofu 1.10 and later) whose resources take a flow's actions as HCL blocks and create, update and delete the flow through the Amazon Connect API: - `flowascode_contact_flow`: a flow, with its Connect type. - `flowascode_contact_flow_module`: a flow module, with its settings. - `flowascode_contact_flow_module_version`: a snapshot of a module, keyed to its content hash. - `flowascode_contact_flow_module_alias`: a named pointer at a version. Its `arn` is the module ARN qualified by the alias id, the only form Connect runs as the alias (contract rule 27 records the run that showed it). - `data.flowascode_view`: an AWS-managed or custom view, by name and type. It is not the emitter. `flow-cli emit --target tf` still writes `aws_connect_contact_flow` resources over rendered content for anyone who stays on `hashicorp/aws`; `--target flowascode` writes the provider's resources. Pick `tf` when the flows are generated artifacts nobody edits as HCL; pick `flowascode` when the flows are authored in HCL, reviewed as HCL, or need plan-time lint. `--target flowascode` writes `flows.tf`, `outputs.tf`, `variables.tf` and `versions.tf.example` for a set of documents, never a per-document `.flow.tf`, so an emit into a directory the studio serves cannot create a companion. `outputs.tf` carries, per document and named by its FlowDoc name, its `arn` and `sha256()` of its `flowdoc`, the document with its references still tokens: the hash a promotion gate compares between environments (`docs/tutorials/02-promote.md`), equal when the documents are and known at plan time, so no root writes its own. A module gets a version and an alias resource per alias a flow in the set invokes it through (rule 27); a module nothing in the set references gets its resource alone, and `flow-cli emit --module-alias module:greeting@live` declares an alias it publishes anyway, in the same shape, so a module released in a root of its own is bound from another root through the address map and the alias ARN `outputs.tf` carries (`packages/cli/README.md`, "emit"). Each document is one resource; its `refs` map binds each key to the address the address map gives, to a resource the set emits itself (a flow, a module, or a module invoked by alias through the version and alias resources the emitter writes beside it), or to `null` under a `# TODO` comment, which the provider refuses at plan time naming the key. Because that `null` validates, `flow-cli emit --target flowascode` refuses the run on any unbound reference, listing each key and its documents, unless `--allow-unbound` asks for the partial map; a map key no reference uses is a warning, or an error under `--strict` (`packages/cli/README.md`, "emit"). The emitted `versions.tf.example` asks for the provider at `~> 0.1`, the range the tutorials and example roots ask for, held to one string by a test. The goldens are `conformance/hcl/emit/`, one case per `emit-tf` case. ## The resource shape ```hcl resource "flowascode_contact_flow" "appointment_line" { instance_id = var.connect_instance_id name = "appointment-line" type = "CONTACT_FLOW" refs = { "hours:main-line" = aws_connect_hours_of_operation.main_line.arn "queue:appointments" = aws_connect_queue.appointments.arn } action { id = "welcome" next = "check-hours" message_participant { text = "Thanks for calling." } error { type = "NoMatchingError" next = "hang-up" } } action { id = "hang-up" disconnect_participant {} } } ``` One `action` block per action, in order. Inside it: `id`, `next`, one typed sub-block named after the action type (`message_participant`, `check_hours_of_operation`, and so on, the catalog's `block` name) or a `generic` block for a type the tooling does not model, then `condition` and `error` blocks, then `position` where the action sits somewhere other than where auto-layout puts it. Reference fields hold a key (`"queue:front-desk"`) that the `refs` map binds to a Terraform expression; a literal ARN is refused at plan time. `conformance/hcl/README.md` has every rule, and its goldens are the byte-exact examples. ## Companions A document has one companion source, `.flow.ts` or `.flow.tf`, paired by suffix and chosen when the document is created. The studio and the bridge treat the two alike: a save regenerates the companion, an edit to the companion re-syncs the canvas, and comments marked `@keep` survive either way. What a `.flow.tf` carries beyond the document (its `refs` bindings, `instance_id`, `tags`, `state` and `lint` settings) is kept across regeneration. `flow-cli convert --to ts|tf` switches a document's companion and says what it drops. The commands that write or read a companion take either kind: `flow-cli codegen --to tf` writes `.flow.tf` (without `--to`, the kind the document's `meta.sourceKind` names), `flow-cli synth .flow.tf` reads one back in process, and `flow-cli export --author tf` writes one per exported document. `flow-cli init --author tf` scaffolds a document with a `.flow.tf`, and `flow-cli studio` watches `.flow.tf` companions as it watches `.flow.ts` ones: an edit re-syncs the canvas within a second, a canvas save regenerates the file keeping what it carries, and a name with both companions is refused until one goes. In the studio the toolbar badges the open document's companion, New flow picks the companion for a new document, and a `.flow.tf`'s `lint` block reaches the lint panel, and "Export as…" offers "Terraform (flowascode provider)", the same bytes as `emit --target flowascode`. `packages/cli/README.md` has the details. ## Versions and compatibility The provider has its own semantic version from v0.1.0 and records the commit of `conformance/` it vendors. The FlowDoc format version it reads is the one the schema under `conformance/schema/` names at that commit. | Provider | FlowDoc | Schema file | Terraform | OpenTofu | | ------------------ | ------- | ----------------------- | ------------- | -------------- | | 0.1.x | 0.2 | flowdoc-0.2.schema.json | 1.8 and later | 1.10 and later | | none released; D10 | 0.3 | flowdoc-0.3.schema.json | 1.8 and later | 1.10 and later | A repository test holds the table's FlowDoc column to `FLOWDOC_VERSION` and its schema file to one present under `conformance/schema/`. The 0.3 row's provider column is filled in by tasks/D10 with the version read from the registries once that release is out; until then a published provider reads 0.2, and an emitted tree carries no FlowDoc version, so the provider refuses only a typed sub-block or a `refs` key type its vendored catalog lacks. An older reader of a document (npm 0.2.x, provider 0.1.x) refuses a 0.3 file by its version, and treats a 0.3 token in a file stamped 0.2 as malformed: the check is the reader's token pattern, not the frozen 0.2 schema, which applies the pattern only at `refs[].token` and the typed reference positions. ## Migrating from hashicorp/aws The provider's guide `migrate-from-aws-provider` (in its registry documentation) has the steps. Two ways in, neither recreating the flow: - A `moved` block from `aws_connect_contact_flow` or `aws_connect_contact_flow_module` (Terraform 1.8, OpenTofu 1.10). The move copies the flow's identity and live attributes; the next plan writes the actions from the configuration, and when they describe the same flow nothing changes in Connect. - `terraform import`, or an `import` block with `-generate-config-out`. The provider reads the live flow back as action blocks, with reference keys in the fields and each key bound in `refs` to the ARN it found in the instance's inventory. Replace those ARNs with the addresses of the resources that manage them: the provider accepts an ARN as a `refs` value (it cannot tell one from a resolved address), but the TypeScript reader, and so the studio, refuses it (`LITERAL_ARN`, rule 20). A flow or module whose Connect name is not a slug (a console's "Main Line") keeps it: `name` becomes the slug and `display_name` the Connect name, so adopting it renames nothing. ## Publishing and the acceptance gate Releases are tagged `v*` in the provider repository. The release workflow checks the tag is on main, the changelog has the version, and the unit and conformance lane passes, with no credentials; goreleaser then builds and signs `SHA256SUMS` in a GitHub Environment whose GPG key and required reviewer mean a tag alone cannot publish. The signing key's fingerprint is in the provider repository's `SECURITY.md`, and its public half is `signing-key.asc` there. Every pull request also runs live acceptance tests against a sandbox Connect instance on both floors (Terraform 1.8, OpenTofu 1.10), and an unreachable sandbox blocks the merge. v0.1.0 was published on 2026-09-29. The Terraform Registry serves it at https://registry.terraform.io/providers/flow-as-code/flowascode, and the OpenTofu registry has listed it since 2026-09-30; `terraform init` and `tofu init` both install it with its signature checked. This repository's emit-tf job validates the provider-shaped output against the published provider on OpenTofu 1.10 and a current release (task B03e). ## Considered and not used Provider-defined functions, a `provider::flowascode::flowdoc()` that would turn HCL into a document at plan time, were considered as a way to keep `hashicorp/aws` as the only resource provider. They were not used: the document would still need API calls only a managed resource can make, and a function cannot carry the plan-time diagnostics the lint rules produce. --- # Simulate: the live run and the dry run A scenario is an ordered list of what a simulated contact hears, sends and reaches, authored once (`conformance/schema/scenario-0.1.schema.json`) and run two ways. The live run executes it on an instance. The dry run holds it against the FlowDocs it will run through, on a laptop or in CI, with no instance at all. They answer different questions, and a suite needs both. ## The scenario ```json { "scenario": "0.1", "name": "keypad-to-appointments", "entryPoint": { "channel": "voice", "flow": "${cdref:flow:keypad-line}" }, "substitutions": [ { "actionType": "TransferContactToQueue", "actionParameters": { "QueueId": "${cdref:queue:appointments}" }, "substitute": "${cdref:queue:overflow}" } ], "steps": [ { "kind": "expect-prompt", "contains": "Thanks for calling the keypad line" }, { "kind": "expect-lambda", "lambda": "${cdref:lambda:caller-lookup}" }, { "kind": "expect-prompt", "contains": "Welcome back, Mrs. Alder" }, { "kind": "expect-prompt", "contains": "press 1" }, { "kind": "send-dtmf", "value": "1" }, { "kind": "expect-hours-check", "hours": "${cdref:hours:main-line}" }, { "kind": "expect-transfer", "queue": "${cdref:queue:appointments}" }, { "kind": "assert", "path": "$.Attributes.callerName", "operator": "Equals", "value": "Mrs. Alder" }, { "kind": "expect-queue", "queue": "${cdref:queue:appointments}" } ] } ``` Every resource is a `${cdref:type:name}` token, the same token the flow uses, never an ARN and never a console name. `expect-transfer` and `expect-queue` both take the queue's token; `expect-queue` may take `name` instead when the console name is what the flow reports in `$.Queue.Name`. The token form of `expect-queue` asserts on `$.Queue.ARN` and has not yet been executed against an instance (the `name` form has); `tasks/C14-simulate-offline-checks.md` records the run it waits on. The test ends after the last step by default, so the simulated contact never reaches an agent; the queue substitution above is the second safeguard AWS documents. ## The dry run ``` flow-cli simulate --dry-run scenarios/ flows/ seasonal/ --address-map refs/dev.tfmap.json ``` No AWS call, no credentials, no SDK. Every path after the scenarios is read into one set, because a scenario runs across a flow and the modules it calls wherever they live; this is the one command where two directories are not two sets. The suite is validated (schema, then the cross-field rules), and each scenario is then held against the set: | It checks | Because otherwise | | ---------------------------------------------------------------- | ------------------------------------------------ | | the entry flow is a document in the set | there is nothing to run | | a resource an `expect-*` event waits on is referenced by the set | the event can never fire | | every other token is referenced by the set or keyed in the map | the live run would refuse the suite | | each `expect-prompt` is a text some block plays | the observation would wait five minutes and fail | | each `send-dtmf` answers a keypad prompt with a key it takes | the flow would take its no-match branch | Only the map's keys are read, so the address map `flow-cli emit` takes serves as well as a resource map of ARNs, and a scenario whose tokens the set itself references needs no map. A prompt that reads `$.Attributes.` is heard with the value the scenario starts the contact with or asserts, which is how "Welcome back, Mrs. Alder" matches `Welcome back, $.Attributes.callerName.` above. SSML is read as its spoken words; a module's text counts like a flow's. The dry run exits 1 listing every problem, as `: : `, so the suite is fixed in one round. Exit 0 means nothing offline says a scenario cannot pass. What it does not do, and why a clean dry run is not a passed scenario: - It does not execute Lambdas. What `${cdref:lambda:caller-lookup}` returns, and so what `$.Attributes.callerName` holds, is the live run's to find out. - It does not evaluate conditions on attribute values, and it does not follow the contact through branches. Every text in the set counts as something the set says, whichever branch plays it, so an expectation of the closed message passes the dry run of a scenario whose hours substitution keeps the line open. - It does not read recorded prompts. A `PromptId` plays audio whose words are not in the document; a miss says how many such prompts the set has. - It does not model the transcript. A voice `expect-prompt` is matched live against speech-to-text of the prompt, which drops punctuation and may spell numbers out ("press one" for "Press 1"). Expect a run of plain words from inside one sentence. The checks are `@flow-as-code/core`'s `dryRunScenario(scenario, docs, options)` and `@flow-as-code/cli/simulate`'s `dryRunSimulate(scenarios, flows, options)` over files, so a consumer's own tests run the same checks the command does. `conformance/simulate/dry-run/` is the contract: a two-document set, a map, and one case per thing the check can say, each with the exact problems it must report. ## The live run ``` flow-cli simulate scenarios/ --instance "$INSTANCE_ARN" --resource-map scenarios/dev.resources.json ``` The resource map holds the ARNs of the deployed resources, keyed any of the three ways the CLI README's "Reference map keys" lists; a run with one unmapped token creates nothing. Each scenario is compiled to the Connect Testing language, created on the instance as a published test case, executed, polled, collected and deleted, within the documented limits (5 concurrent, 100 in flight, 5 minutes per scenario). The report is JUnit or the `flow-simulate-report/0.1` JSON; exit 0 only when every scenario `PASSED`. https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html The live run is the only one that knows what a Lambda returned, which branch the contact took, what the transcript heard and whether the queue reached is the one expected. It costs a deployed environment and AWS credentials, so it is an operator step or a gated job, not a unit test. The dry run is the unit test, and it is what catches the scenario written for a prompt the flow no longer says before the live run spends five minutes timing out on it. ## Where each lives | Concern | Where | | ------------------------------ | -------------------------------------------------------------------------- | | scenario format and validation | `conformance/schema/scenario-0.1.schema.json`, `validateScenario` | | compilation to a test case | `compileScenario`, goldens in `conformance/simulate//` | | offline checks | `dryRunScenario`, `conformance/simulate/dry-run/` | | the runner and its limits | `runScenarios`, `SIMULATE_LIMITS` | | reports | `junitReport`, `jsonReport`, `conformance/simulate/report/` | | the command, both runs | `packages/cli/README.md`, "simulate" | | what was verified live, when | `tasks/A06-export-and-simulate.md`, `tasks/C14-simulate-offline-checks.md` | --- # Promote one flow across two environments One FlowDoc, deployed to a dev environment and a prod environment, on every deploy path, with no per-environment ARN table anywhere. This is the claim the rest of the repository makes in prose (`README.md`, `docs/adr/0004-prior-art-aws-l2-cdk-library.md`), demonstrated with commands you can run. `tests/promoteAcrossEnvironments.test.ts` runs the same inputs on every push and holds the claim itself: the two emitted trees differ in exactly one file, that file carries only its own environment's addresses, and no emitted byte is an ARN. The outputs pasted below are transcripts of a real run, not assertions, so read them as illustration and trust the test for the claim. ## What is here Inputs only. Everything the tools generate is written into `build/` or `cdk.out/`, both gitignored, by the commands below. ``` flows/appointment-line.flowdoc.json the flow, written once refs.dev.tfmap.json dev's terraform ADDRESSES, one per reference refs.prod.tfmap.json prod's terraform addresses, same references terraform/dev/ your own dev configuration: provider, resources terraform/prod/ your own prod configuration: provider, remote state flowascode/dev/, flowascode/prod/ the same halves for the flowascode provider cdk/app.ts one CDK app, two stacks, two binders ``` The flow is the demo appointment line: it checks hours of operation, invokes a Lambda to look up an appointment, and transfers to a queue. It refers to those three resources by name, never by ARN: ``` ${cdref:hours:main-line} ${cdref:lambda:appointment-lookup} ${cdref:queue:appointments} ``` Run everything below from this directory, with the workspace built once from the repository root (`npm ci && npm run build`). `npx flow-cli` then resolves to the workspace copy. ## Step 1: the flow is written once ``` npx flow-cli lint flows/ ``` ``` No findings. ``` A literal ARN in authored content fails a hard lint rule, so this document could not carry one even if someone pasted it in. There is one copy of it, and both environments below read that copy. ## Step 2: the Terraform path Emit the same document twice, once per environment, changing nothing but the address map: ``` npx flow-cli emit flows/ --target tf --address-map refs.dev.tfmap.json --out build/dev npx flow-cli emit flows/ --target tf --address-map refs.prod.tfmap.json --out build/prod ``` Each writes five files. Now compare the two trees: ``` diff -r build/dev build/prod ``` ``` diff -r build/dev/flow_refs.tf build/prod/flow_refs.tf 10,12c10,12 < hours_main_line_arn = aws_connect_hours_of_operation.main_line.arn < lambda_appointment_lookup_arn = aws_lambda_function.appointment_lookup.arn < queue_appointments_arn = aws_connect_queue.appointments.arn --- > hours_main_line_arn = data.terraform_remote_state.platform.outputs.main_line_hours_arn > lambda_appointment_lookup_arn = data.terraform_remote_state.platform.outputs.appointment_lookup_arn > queue_appointments_arn = data.terraform_remote_state.platform.outputs.appointments_queue_arn ``` That is the whole difference between the two environments: one file, `flow_refs.tf`, holding three addresses. `flows.tf`, `variables.tf`, `versions.tf.example` and the rendered flow template `flows/appointment-line.flow.tftpl` are byte-identical, because the template refers to `local.flow_refs` by variable names derived from the tokens, not from whatever the addresses resolve to. Neither tree contains an ARN: ``` grep -ril "arn:aws" build/ || echo "no ARN in either tree" ``` ``` no ARN in either tree ``` ### That table is not the table we said we would delete `--address-map` takes a table, and the project's own summary says the mapping table is the thing we wanted to delete: "references resolved as tokens, never hand-mapped ARNs", at the top of the repository README. The landing page at makes the same point in its own words. Both are true, because they are not the same table. The thing being deleted is a table of ARNs: "the queue I mean" to "the ARN it has in this account, this region, this instance", maintained by hand, one row per environment per resource, wrong the day someone recreates a queue. `refs.dev.tfmap.json` is a table of pointers into IaC that already exists. Its values are Terraform addresses of resources your own configuration manages, and they are not per-environment values at all: they are per-environment _configurations_. `emitTf` enforces the distinction rather than trusting it. `checkExpression` (`packages/tf/src/emit.ts`) refuses any value matching `/arn:aws/i` outright, so this file cannot decay into an ARN table: ``` Cannot emit terraform: - address for ${cdref:queue:appointments} is a literal ARN (arn:aws:connect:...); map to a terraform address instead ``` (the ARN is elided here; the tool prints it in full, on one line) The two paths differ here because the two tools differ. In CDK the flows and the resources are objects in one program, so the app already holds the reference and there is nothing to name: the binder just hands over `queue.attrQueueArn`. HCL has no equivalent, so something has to name the address in text. That, and only that, is why one path takes a map and the other takes none. ### Deploy it The emitted files are not a root module on their own: they carry no provider, no backend and no credentials, deliberately. Your configuration supplies those and the resources the addresses point at. `terraform/dev/` and `terraform/prod/` are that half, and they are where the two environments actually differ: - `terraform/dev/` creates the hours, the queue and the Lambda, so `refs.dev.tfmap.json` points at resources in the same root module. - `terraform/prod/` creates none of them. A platform team manages them and publishes their ARNs as outputs, so `refs.prod.tfmap.json` points at `data.terraform_remote_state.platform.outputs.*`. Assemble each root module and check it with the real tool: ``` cp terraform/dev/*.tf build/dev/ cp terraform/prod/*.tf build/prod/ (cd build/dev && tofu init -backend=false -input=false && tofu validate) (cd build/prod && tofu init -backend=false -input=false && tofu validate) ``` ``` Success! The configuration is valid. Success! The configuration is valid. ``` `tofu plan` and `tofu apply` need credentials for the target account and the instance id (`TF_VAR_connect_instance_id`, which the emitted `variables.tf` declares and nothing in this repository has a value for). ## Step 2b: the flowascode provider path The same flow, the same two address maps, emitted for the `flow-as-code/flowascode` provider instead: the flow becomes one `flowascode_contact_flow` resource with an `action` block per action, and the provider creates it through the Amazon Connect API rather than through `aws_connect_contact_flow`. ``` npx flow-cli emit flows/ --target flowascode --address-map refs.dev.tfmap.json --out build/flowascode-dev npx flow-cli emit flows/ --target flowascode --address-map refs.prod.tfmap.json --out build/flowascode-prod diff -r build/flowascode-dev build/flowascode-prod ``` ``` diff -r build/flowascode-dev/flows.tf build/flowascode-prod/flows.tf 14,16c14,16 < "hours:main-line" = aws_connect_hours_of_operation.main_line.arn < "lambda:appointment-lookup" = aws_lambda_function.appointment_lookup.arn < "queue:appointments" = aws_connect_queue.appointments.arn --- > "hours:main-line" = data.terraform_remote_state.platform.outputs.main_line_hours_arn > "lambda:appointment-lookup" = data.terraform_remote_state.platform.outputs.appointment_lookup_arn > "queue:appointments" = data.terraform_remote_state.platform.outputs.appointments_queue_arn ``` Four files each (`flows.tf`, `outputs.tf`, `variables.tf`, `versions.tf.example`), and the difference is the resource's `refs` block: the action blocks name `"queue:appointments"`, and only `refs` says which queue that is in each environment. Neither tree holds an ARN. `outputs.tf` is the same in both: `appointment_line_arn` and `appointment_line_document_sha256`, the hash of the flow with its references still tokens, which is how a pipeline can see that prod runs the document dev ran. `flowascode/dev/` and `flowascode/prod/` are the environment halves for this path. Their `resources.tf` files are the Terraform path's, byte for byte (the test holds them equal); their `providers.tf` adds the flowascode provider beside hashicorp/aws. The provider is on the Terraform and OpenTofu registries, and the test validates both assembled trees against it (from OpenTofu 1.10, its floor). The [Terraform provider example](../terraform-provider/README.md) goes further with the same flow: it is authored as HCL in a module and applied to both environments. ## Step 3: the CDK path There is no map here at all. `FlowSet` takes a `TokenBinder`, and each binder returns the ARN of a resource the CDK app itself knows about, so the ARNs come from the thing that created or imported them. `cdk/app.ts` is what `flow-cli emit flows/ --target cdk` scaffolds, edited the way its own banner sanctions. The scaffold writes one `const binder: TokenBinder` at module level, because the common case is one environment; promoting means moving that constant into the stack so each stack binds its own resources. The two stacks then differ the way real environments do: - `DevFlowStack` creates the hours, the queue and the Lambda. Its binder returns construct attributes (`queue.attrQueueArn`), which synthesize to `Fn::GetAtt`. - `ProdFlowStack` creates none of them and imports the platform team's exports. Its binder returns `Fn.importValue(...)`, which synthesizes to `Fn::ImportValue`. Each stack's instance ARN is read at deploy time from an SSM parameter in the target account, so no instance ARN is committed either. ``` npx tsx cdk/app.ts ``` That synthesizes both stacks into `cdk.out/` (in a real project this file is your `bin/app.ts` and the CDK CLI runs it). What the two templates hold: ``` grep -o '"connect-platform-[a-z-]*"' cdk.out/AppointmentLine-prod.template.json | sort -u ``` ``` "connect-platform-appointment-lookup-arn" "connect-platform-appointments-queue-arn" "connect-platform-main-line-hours-arn" ``` ``` grep -c 'Fn::ImportValue' cdk.out/AppointmentLine-dev.template.json ``` ``` 0 ``` Dev has none, because it binds its own resources. And neither template names a Connect ARN: ``` grep -il "arn:aws:connect" cdk.out/*.template.json || echo "no Connect ARN in either template" ``` ``` no Connect ARN in either template ``` The flow content itself is the same text in both templates, with three holes: the test asserts that the literal text around the bound values is byte-identical between the two stacks and that all three bound values differ. ## Step 4: the test that holds this From the repository root: ``` npx vitest run --project repo tests/promoteAcrossEnvironments.test.ts ``` ``` Test Files 1 passed (1) Tests 23 passed | 4 skipped (27) ``` It reads the files in this directory, emits both Terraform trees, asserts the difference set is exactly `["flow_refs.tf"]`, asserts each `flow_refs.tf` holds its own environment's addresses and no ARN, does the same for the two flowascode trees (difference set `["flows.tf"]`, and only inside `refs`), and synthesizes the CDK app to compare the two stacks. The four skipped tests are `tofu validate` of step 2's two trees and step 2b's two, which need a real `tofu` and provider downloads (step 2b's from OpenTofu 1.10, the flowascode provider's floor). They are gated on `RUN_TOFU_VALIDATE=1`, like every other test in this repository that runs the real tool, and the `emit-tf` CI job sets it: ``` RUN_TOFU_VALIDATE=1 npx vitest run --project repo tests/promoteAcrossEnvironments.test.ts ``` ``` Test Files 1 passed (1) Tests 27 passed (27) ``` ## What this example does not do Five things are worth saying plainly, because the walkthrough above could be read as claiming them. **Nothing here diffs two environments against each other.** `flow-cli diff` is always local documents against one live instance (`--instance `), so "is prod behind dev" is two runs and a human reading two outputs. There is no command that answers it, and this example does not add one. **A partial address map succeeds.** `emit --target tf` with a map missing a reference does not fail. It writes `TODO_MISSING_ADDRESS_` into `flow_refs.tf` and exits 0; the failure arrives later, at `tofu validate`, as an undefined reference naming the token. That is deliberate (a loud placeholder beats a half-written tree), but it means a CI job that emits without validating will not catch a map that has fallen behind the flow. Emitting this example's document against a map holding only `hours:main-line` exits 0 and writes: ``` # TODO: no terraform address for ${cdref:lambda:appointment-lookup}. lambda_appointment_lookup_arn = TODO_MISSING_ADDRESS_lambda_appointment_lookup ``` `conformance/emit-tf/demo-incomplete-map/` is that case as a fixture. **The CDK scaffold is single-environment.** `flow-cli emit --target cdk` writes one module-level `const binder`, which a two-environment app has to edit. The scaffold's banner sanctions exactly that ("yours to keep: re-running the command overwrites this file, so move it or rename it once you have edited it"), but the generator does not offer a two-environment shape, and `cdk/app.ts` is a hand edit rather than generated output. **The two CDK templates differ because these two stacks differ.** Dev creates its resources and prod imports them, which is realistic and is what makes the difference visible in synthesized JSON. If both environments created their resources the same way, the two templates would be identical text, and the difference would live entirely in the account each stack deploys into, where no test in this repository can see it. The Terraform half is the stronger proof for that reason: two trees, on disk, differing in one file. **Nothing here is deployed.** No command above reaches AWS or needs credentials (`tofu init` talks to the provider registry and nothing else). The live half needs an instance ARN and a Connect instance per environment; the CDK package's `src/integration.test.ts` is the piece that has been run against a real instance. --- # The Terraform provider example One flow, authored in HCL for the `flow-as-code/flowascode` provider, deployed to a dev and a prod environment from the same commit, and a cookbook of smaller flows beside it. The tutorials walk through it step by step; this page is the map of the files. Everything here was applied to a live Amazon Connect instance on 2026-09-29 with Terraform 1.8.5 and provider v0.1.0 from the Terraform Registry, then destroyed. `tests/terraformProviderExample.test.ts` holds the parts that do not need an AWS account on every push: every flow reads back through the HCL reader, lints clean, and is already `terraform fmt` output; the flows module's companion regenerates byte for byte; the environments differ only where they should. ## What is here ``` flows/ a Terraform module holding the flows appointment-line.flow.tf the flow, as a flowascode_contact_flow resource appointment-line.flowdoc.json the same flow as a FlowDoc, for the studio variables.tf the instance, and one ARN per reference outputs.tf the flow's ARN and its document hash, as flow-cli emit writes them versions.tf the provider the module needs envs/dev/ dev: creates its own hours, queue and function envs/platform/ a platform team's configuration prod reads envs/prod/ prod: reads the platform's state cookbook/ eight flows, one pattern each, and what they bind ci/promote-flows.yml a GitHub Actions pipeline: plan, dev, then prod ``` ## The flow is written once `flows/appointment-line.flow.tf` is the demo appointment line: it checks hours of operation, sets a queue, looks the caller up with a Lambda function, and transfers. It names what it uses by reference key, never by ARN: ```hcl check_hours_of_operation { hours_of_operation_id = "hours:main-line" } ``` and binds each key once, in the resource's `refs` map, to a variable of the module: ```hcl refs = { "hours:main-line" = var.main_line_hours_arn "lambda:appointment-lookup" = var.appointment_lookup_arn "queue:appointments" = var.appointments_queue_arn } ``` The module has no provider block and no credentials. Each environment's root module configures the providers and passes its own ARNs in. ## Two environments, one module call each `envs/dev/main.tf` creates the hours, the queue and the function, and calls the module with their attributes. `envs/prod/main.tf` creates none of them; it reads a platform team's state and calls the same module with its outputs. The module call is the only place the two differ: ```hcl module "flows" { source = "../../flows" connect_instance_id = var.connect_instance_id main_line_hours_arn = aws_connect_hours_of_operation.main_line.arn appointment_lookup_arn = aws_lambda_function.appointment_lookup.arn appointments_queue_arn = aws_connect_queue.appointments.arn } ``` ```hcl module "flows" { source = "../../flows" connect_instance_id = var.connect_instance_id main_line_hours_arn = data.terraform_remote_state.platform.outputs.main_line_hours_arn appointment_lookup_arn = data.terraform_remote_state.platform.outputs.appointment_lookup_arn appointments_queue_arn = data.terraform_remote_state.platform.outputs.appointments_queue_arn } ``` Both applies printed the same `appointment_line_document_sha256`, `3d12a6cd22f8e04018712c78915e89cef32b09d60849b573a7af41f14a641e65`: the hash of the flow as a FlowDoc, with its references still tokens. Prod ran the document dev ran. Connect holds a different ARN in each environment, and only there. ## Running it You need Terraform 1.8 or later or OpenTofu 1.10 or later (the provider is on both registries), credentials for an AWS account, and the id of an Amazon Connect instance in it. The dev and platform roots also need an `appointment-lookup.zip` for the function, which is yours to supply; any Node.js handler returning a string map will do. ``` cd envs/dev export TF_VAR_connect_instance_id= terraform init terraform apply ``` The region is in each root's `providers.tf`. The tutorials show the output of every step and what to change for your own flows: - [Your first flow with the Terraform provider](../../docs/tutorials/01-first-flow.md) - [Promote a flow from dev to prod](../../docs/tutorials/02-promote.md) - [Bring existing flows under Terraform](../../docs/tutorials/03-adopt.md) - [Edit a .flow.tf in the studio](../../docs/tutorials/04-studio.md) - [The flow cookbook](cookbook/README.md) --- # @flow-as-code/cli Install with `npm i -D @flow-as-code/cli`. Apache-2.0, Node 22.12 or newer. Installing it brings the other five, so `npx flow-cli` works straight away. `npx flow-cli init flows` writes a document to open, and `npx flow-cli studio flows` opens it. [`examples/promote-across-environments/`](https://github.com/flow-as-code/flow-as-code/tree/main/examples/promote-across-environments) is a worked run of `lint` and `emit` against one document and two environments. Site and docs: . This package on npm: . ``` flow-cli init [dir] [--author ts|tf] scaffold a directory to open flow-cli lint [--format text|json] rule set over each document set flow-cli render --resources map.json standalone materialization flow-cli codegen [--to ts|tf] [--out ] [--banner ] FlowDoc -> companion flow-cli synth [--out ] companion -> FlowDoc (TS in a sandboxed child) flow-cli convert --to ts|tf [--address-map ] [--keep-old] [--force] flow-cli emit --target cdk|flowascode|tf [--address-map refs.tfmap.json] [--instance-id-expression ] [--allow-unbound] [--strict] [--module-alias module:name@alias]... flow-cli diff --instance local FlowDocs vs the live instance flow-cli export --instance [--out ] [--author ts|tf] [--no-codegen] [--on-error abort|collect] flow-cli simulate --instance [--resource-map ] [--format junit|json] [--out ] simulate --dry-run [--resource-map | --address-map ] offline checks, no instance flow-cli studio [dir] [--port ] local visual editor, live sync both ways ``` ## Install-script warning for esbuild A fresh `npm install` on npm 11 prints ``` npm warn install-scripts 1 package has install scripts not yet covered by allowScripts: npm warn install-scripts esbuild@0.28.2 (postinstall: node install.js) ``` The chain is `@flow-as-code/cli -> tsx -> esbuild`: `flow-cli synth` runs a `.flow.ts` builder file in a child process with `tsx` registered through `--import`, and `tsx` is built on esbuild, whose postinstall fetches its platform binary. The dependency stays a regular one, because every `synth`, `convert --to ts` and studio save of a `.flow.ts` needs it and an optional dependency would turn the first `synth` into a run-time error about a missing loader. The warning is npm 11's script allowlisting (https://docs.npmjs.com/cli/v11/commands/npm-install-scripts) and it is answered once per project: ``` npm install-scripts approve esbuild ``` which records the approval in `package.json` and runs the script. Checked against npm 11.19.1 on 2026-10-05. ## Availability Every command above is wired, so `--help` is the complete surface; a test in `src/cli.test.ts` holds `--help` and the usage block above to the same list. | Command | Status | Notes | | ---------- | --------- | ----------------------------------------------------------------------------- | | `init` | available | One demo FlowDoc plus its `.flow.ts`; refuses to overwrite | | `lint` | available | Whole set in one pass, so cross-document rules resolve module references | | `codegen` | available | `.flow.ts` or `.flow.tf`; `@keep` comments in an existing output file survive | | `synth` | available | A `.flow.ts` in a sandboxed child process (see below); a `.flow.tf` parsed | | `convert` | available | Switches a document's companion; says what the new one does not carry | | `render` | available | Strict resource map; every unmapped token is listed at once | | `emit` | available | `tf` and `flowascode` write Terraform, `cdk` writes a scaffold | | `export` | available | Live instance to FlowDoc pairs; collects failures by default | | `simulate` | available | Scenario suite through the TestCase API, JUnit or JSON report | | `diff` | available | Local documents against the live instance; exit 2 when it cannot tell | | `studio` | available | Local bridge on 127.0.0.1 serving the studio, with live sync both ways | Exit codes: 0 on success, 1 for a failure (a missing path, malformed JSON, a schema-invalid document, an unmapped token, an unknown flag value, a lint finding with error severity, a flow that could not be exported, a scenario that did not pass, or a document that differs from the instance). `diff` alone also uses 2, for an error in the comparison itself, so a script can tell "differs" from "could not tell". ## Importing it The command lives in `dist/bin.js` and nothing else loads it, so importing the package never parses argv or exits the host process. The package root is a library entry, the union of the four subpaths: ```ts import { synthFile, createWatcher, startStudioServer, scenarioProblems } from "@flow-as-code/cli"; // or, narrower: import { synthFile } from "@flow-as-code/cli/synth"; import { createWatcher } from "@flow-as-code/cli/watch"; import { startStudioServer } from "@flow-as-code/cli/bridge"; import { scenarioProblems, dryRunSimulate } from "@flow-as-code/cli/simulate"; ``` `@flow-as-code/cli/simulate` is what a consumer's own tests reach for to check a scenario suite without an instance: `scenarioProblems(value)` is the schema plus cross-field validation the command runs on every scenario file, as a list of strings, empty when the value is a valid scenario; `loadScenarios(path)` reads and validates a file or directory the way the command does; `dryRunSimulate(scenarios, flows, { resourceMap | addressMap })` is the whole dry run below, throwing a `CliError` that lists every problem. The set-level check itself, `dryRunScenario(scenario, docs, { resourceMap })`, is `@flow-as-code/core`'s and takes parsed objects, for a test that already has them in hand. `src/index.test.ts` imports the built root in a child process and fails if it writes anything or exits, and holds the root's exports equal to the union of the four subpaths. ## Connecting to an instance `export`, `simulate`, and `diff` take `--instance `, the instance ARN as the console's account overview shows it (https://docs.aws.amazon.com/connect/latest/adminguide/find-instance-arn.html). Only an instance ARN is accepted: a bare instance id carries no Region, and a resource ARN names the wrong thing. The Region is read from the ARN. Nothing consults `AWS_REGION` or a profile's region, so an instance in one Region is never addressed through a client configured for another (https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html). Credentials come from the AWS SDK's default provider chain: environment variables, the shared config and credentials files with their profiles and SSO sessions, web identity, and the container and instance metadata endpoints (https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html). The CLI takes no credential flags and stores nothing. Pick a profile with `AWS_PROFILE` as you would for the AWS CLI. `@aws-sdk/client-connect` is an optional peer dependency, loaded only when one of these three commands runs, so the offline commands do not pay for it. When it is not installed the command exits 1 with one line naming the package and the install command, no stack trace. An SDK that is installed but will not load (a dependency of its own missing, a broken install) is a different failure and is reported as `@aws-sdk/client-connect could not be loaded:` with the loader's reason, since the install command is not the fix. `simulate` also checks that the installed SDK exposes the TestCase operations, which are recent, and names the version that has them all when it does not. `@aws-sdk/client-qconnect` is a second optional peer, read by `export` and `diff` when it is installed: it lists the account's AI agents assistants (Amazon Q in Connect `ListAssistants`), so a `CreateWisdomSession` exports its assistant as an `assistant` reference. Without it the command runs as before, names no assistant, and leaves an assistant ARN in flow content literal, which export reports as it does any ARN it cannot name. It needs `wisdom:ListAssistants` beside the Connect read permissions. `@aws-sdk/client-connectcases` is a third, read the same way: it finds the Cases domain the instance's `CASES_DOMAIN` integration association names and lists its templates and fields (Cases `ListTemplates` and `ListFields`), so a `CreateCase`, `GetCase` or `UpdateCase` exports its template and the domain's own fields as `casetemplate` and `casefield` references. A flow holds them by bare id, not ARN, and export names an id only where the catalog says a template or field stands; a system field such as `summary` stays literal, since it is the same on every domain. Without the package, or on an instance with no Cases domain, the ids stay literal, which the service accepts and codegen keeps as a generic block. It needs `connect:ListIntegrationAssociations`, `cases:ListTemplates` and `cases:ListFields` beside the Connect read permissions. Every other command works with no SDK installed and makes no network calls. ## init `flow-cli init [dir]` writes the smallest complete thing to open, so the npm path has a first step: ``` npm i -D @flow-as-code/cli npx flow-cli init flows npx flow-cli studio flows ``` Three files, into the working directory or `[dir]`: | File | What it is | | ------------------------------- | ----------------------------------------------------------- | | `appointment-line.flowdoc.json` | The demo FlowDoc, the same one the hosted studio demo opens | | `appointment-line.flow.ts` | Its paired builder file, generated by `codegen` | | `package.json` | Only when no `package.json` exists above the directory | The pair is written through the same path a canvas save takes, so the document carries the `meta.sourceHash` of the builder file beside it and the two open in sync rather than as a conflict on the first edit. The `package.json` declares `"type": "module"`; a directory that already sits inside a project gets none, because `synth` evaluates a builder file as ESM whatever the project declares and there is nothing to fix. `--author tf` writes `appointment-line.flow.tf`, the document as a `flowascode_contact_flow` resource, in place of the builder file, and no `package.json`: HCL is parsed, not run. Its `refs` values start as `null` under TODO comments; bind them to your Terraform addresses. A directory that already holds other files is fine. A directory that already holds one of the files this writes, or the other companion, is not: nothing is written at all and the command exits 1 naming every collision, since half a pair is worse than none. ## Input and output conventions `lint`, `render`, and `emit` take a directory or a single file. A directory contributes every `*.flowdoc.json` in it, sorted by name, and is not walked recursively. Every document is validated against the schema of the version its `flowdoc` field names (`flowdoc-0.1.schema.json`, `flowdoc-0.2.schema.json` or `flowdoc-0.3.schema.json`, byte copies of `conformance/schema/` this package ships) and then migrated to the current version, before any command looks at it, so a malformed document fails with its own path named rather than deep inside an emitter. `--out` defaults to the input directory for every command that writes, and `codegen` names its file after the document (`appointment-line.flowdoc.json` generates `appointment-line.flow.ts`), which is the inverse of what `synth` writes. ## lint Each argument is a set, and the whole set goes to `@flow-as-code/core`'s lint in one call. Rules receive the other documents alongside their own, and the ones that follow module references or attribute writes cannot answer without them: `module-depth-5` and `attribute-set-before-read` on a single file report nothing, correctly, because neither is computable from one document. Several arguments are several sets, never one merged set. `flow-cli lint flows/ seasonal/` lints exactly as two runs would, in one process: a module in `seasonal/` does not resolve a reference in `flows/`, an attribute set in one does not cover a read in the other, and every finding names the set it came from. With one set the text report is `@flow-as-code/core`'s; with more, each set's report follows its path and a line across sets closes it. Exit status follows severity, not count, and the worst across sets: any `error` exits 1, warnings alone exit 0; the failure line names the sets with errors. `--format json` prints `flow-lint-report/0.1` on stdout; the failure line goes to stderr, so the JSON stays clean for a pipe. Its schema is `conformance/schema/lint-report-0.1.schema.json`, and `src/lint.test.ts` holds the command's output to it. A change to the shape is a new id and a new schema; this one is: | Field | What it holds | | --------------------- | ------------------------------------------------------------------------------------------- | | `format` | `"flow-lint-report/0.1"` | | `summary` | `{ total, errors, warnings }` across every set | | `sets[]` | One per argument, in argument order | | `sets[].set` | The argument, resolved to an absolute path | | `sets[].documents` | The names of the FlowDocs in that set, in file order | | `sets[].summary` | `{ total, errors, warnings }` for that set | | `findings[]` | Every finding, set by set in argument order; within a set by document, rule, block, message | | `findings[].set` | The set it came from, as `sets[].set` spells it | | `findings[].rule` | The rule id, stable and never renamed | | `findings[].severity` | `"error"` or `"warning"` | | `findings[].doc` | The name of the FlowDoc | | `findings[].blockId` | The action Identifier, present when the finding is about one action | | `findings[].message` | The finding's sentence | A script that read `summary` and `findings` from the report before it had a `format` still reads them: those fields are where they were, and `findings[]` gained `set`. Every document is schema-checked before any rule runs, so a document the schema rejects never reaches the report. A literal ARN is the one violation that is both a schema failure and a named rule, and the schema states it four ways per field without ever saying "literal ARN", so it is reported once per offending field as `no-literal-arn: "" contains a literal ARN`. Schema errors the ARN did not cause are still listed. ## render Materializes each document with `materializeWithMap` and writes `.json`. This is the one path where a literal ARN is the goal rather than a mistake, so map values are not linted. Completeness is enforced: a reference with no entry in the map is fatal, and every missing reference across every document is printed in one run, each with the key forms it would have taken. ### Reference map keys `render --resources`, `simulate --resource-map`, and `emit --target tf --address-map` read the same shape of file and accept the same three keys per reference, so one map's keys serve all three: | Key | Where it comes from | | ----------------------------- | ---------------------------------------------------- | | `${cdref:module:survey@prod}` | The token, exactly as the document writes it | | `module:survey@prod` | The token without its wrapper | | `module_survey_prod_arn` | The variable `@flow-as-code/tf` emits in `flow_refs` | The middle form is what `@flow-as-code/tf` writes into its own TODO comments and what [`examples/promote-across-environments/`](https://github.com/flow-as-code/flow-as-code/tree/main/examples/promote-across-environments) uses, and it is the one to prefer: it is the shortest that still says which resource it means. `render` and `simulate` took only the token before 0.1.2, so a map written from an emit run was rejected by the next command. The keys are shared; the values are not. `render` and `simulate` want the ARN of a resource that exists, and `render` is the one path where a literal ARN is the goal rather than a mistake. `emit --target tf --address-map` wants an HCL address expression and refuses a literal ARN, because an emitted flow that hardcodes one is the thing this tooling exists to stop. So a `render` map and an `emit` map are two files with the same keys. ## emit Each argument is a set and is emitted on its own, into its own directory, so module aliasing and reference resolution never reach across sets, exactly as `lint` keeps them apart: `flow-cli emit flows/ seasonal/ --target flowascode` writes `flows/flows.tf` and `seasonal/flows.tf` from their own documents, as two runs would. `--out` names one directory, so with more than one set it is refused rather than letting the second set overwrite the first; so are two sets that would resolve to the same directory. The paths printed are every set's, in argument order. A run over several sets is two-phase: every set goes through its emitter first, and nothing is written unless every set passed, so an unbound reference or a refused `--strict` in the second set leaves the first set's directory as it was; the message names the set. One address map serves every set, so a key is unused only when no set uses it: a key `flows/` binds and `seasonal/` never mentions is not warned about, and `--strict` refuses only a key no set uses, once. `--allow-unbound` and `--instance-id-expression` apply to every set. `--module-alias module:@` goes to the set(s) whose documents hold that module and is refused, once, naming the sets searched, when none does; with one set it is handed to the emitter as given, whose refusal names the module. `outputs.tf` is written per set, for that set's flows. `--target tf` is a thin wrapper over `@flow-as-code/tf`, writing exactly the bytes the emitter returns and nothing of its own. `--address-map` is passed through as `options.addressMap` and takes the same three key forms ("Reference map keys" above); without one, unresolved references become the emitter's loud `TODO_MISSING_ADDRESS_*` placeholders, which fail `terraform validate` rather than deploying a broken flow. That placeholder is the flat target's whole answer to an unbound reference: the command exits 0 and `validate` is where the run stops, so `--allow-unbound` changes nothing on this target and is accepted so one flag list serves both. `--target flowascode` writes the set for the `flow-as-code/flowascode` provider, exactly the bytes `@flow-as-code/hcl`'s `emitFlowascode` returns: `flows.tf` with one resource per document and its actions as blocks, `outputs.tf` with an `_arn` and an `_document_sha256` output per document, named by FlowDoc name, for a promotion gate to read (`docs/tutorials/02-promote.md`), `variables.tf`, and `versions.tf.example`. It takes the same address map. A reference it resolves nowhere is an error: the command exits 1, writes nothing, and lists each key with the documents that make it, because the `null` binding the emitter would write validates and is refused only at plan time, when the provider names the key. `--allow-unbound` writes the partial map anyway, each unbound key as `null` under a `# TODO` comment, and exits 0; that is the shape to review or to finish binding in the file, not one to apply. It never writes a `.flow.tf`, so an emit into a directory `flow-cli studio` serves cannot create a companion. docs/06-terraform-provider.md says when to pick `flowascode` over `tf`. On either target, an address map key that no reference in the set reaches by any of its three forms is a warning on stderr (`warning: address map key "queue:apointments" matches no reference in the set`), one line per key, and the command still exits 0: a typo in a key, or a key left behind when a flow stopped using it, is otherwise silent. `--strict` makes it an error, exiting 1 and writing nothing. A key the set reaches but resolves itself (a flow or module the set emits) is not unused; the emitter ignores its value and, on the flat target, says so in `flow_refs.tf`. Both flags are refused on `--target cdk`, which binds through a `TokenBinder` and takes no map. On either target, a module gets a version and an alias resource for each alias a flow in the set invokes it through, and a module nothing in the set references gets none (on `tf`, a version and no alias): the emitter cannot know what a root elsewhere will bind. `--module-alias module:greeting@live`, repeatable, declares an alias the module publishes anyway, written exactly as for an invoked module, so a module released in a root of its own can be bound as `module:greeting@live` from another root's address map; on `flowascode`, `outputs.tf` adds `greeting_live_arn` for that root to read. The value is the key a flow binds, `module:@`, slugs on both sides; a module the set does not emit is refused. The flag is refused on `--target cdk`. The emitted version and alias carry no `description`, so a root moving from a hand-written pair with descriptions (the cookbook's `module-release.tf`) to the emitted one sees a plan that drops them, an in-place update; keep the hand-written resources if the descriptions matter, or accept the change. `--instance-id-expression ` is the emitter's `instanceIdExpression`: the HCL expression every resource's `instance_id` takes. Without it the expression is `var.connect_instance_id` and `variables.tf` declares that variable; with it no `variables.tf` is written, so a root that declares `connect_instance_id` itself (or reads the instance from a data source or a remote state) takes the whole emitted tree instead of copying `flows.tf` out of it. The expression is checked the way an address map value is: not empty, not a literal ARN, one line, no comment marker. ``` flow-cli emit flows/ --target flowascode --instance-id-expression local.connect_instance_id flows/flows.tf flows/versions.tf.example ``` `--target cdk` is not a code generator, because `@flow-as-code/cdk` is a library: `FlowSet` reads the FlowDoc directory itself at synth time. What the command writes is `flow-stack.ts`, a compiling scaffold that constructs a `FlowSet` over the directory and declares a `TokenBinder` carrying one `TODO` per reference type the documents actually use, with the referenced names listed. Reference types the set does not use still get a method, because `TokenBinder` requires them, but theirs throws instead of carrying a TODO. Module references never appear: `FlowSet` resolves those itself against the modules it manages. Re-running the command overwrites the file, so move or rename it once you have filled it in. The generator lives in `@flow-as-code/cdk/scaffold`, not here, because the studio's CDK export button has to produce the same bytes and cannot import this package (`@flow-as-code/cli` depends on `@flow-as-code/studio` for its built assets). What stays here is the path arithmetic: turning `--out` and the documents' directory into the relative `FLOW_DOCS` expression. The scaffold resolves that expression against its own file (`new URL(..., import.meta.url)`), so `cdk synth` works from the project root, where `cdk.json` normally lives, and not only from the directory the scaffold was written to. ## codegen and convert `flow-cli codegen [--to ts|tf] [--banner ]` writes the document's companion: `.flow.ts` (builder TypeScript) or `.flow.tf` (a `flowascode_contact_flow` resource, `@flow-as-code/hcl`'s `fromFlowDoc`). Without `--to` it writes the kind the document's `meta.sourceKind` names, and `ts` when it names none. An existing output file is read first: its `@keep` comments survive, and a `.flow.tf` also keeps its `refs` bindings, `instance_id`, `tags`, `state`, `lint`, `provider`, `lifecycle` and `depends_on`. Writing the document's own companion (no `--out`, and the kind its `meta.sourceKind` names) also restamps the document's `meta.sourceHash` with the hash of what was written, so the pair stays in sync for the watcher. It refuses to write a companion beside one of the other kind; that is `convert`'s job. `--banner ` (TypeScript only) writes one extra comment line after the two fixed ones, for a generator that owns the document to say so: ``` // Generated by @flow-as-code/core codegen from main-line.flowdoc.json. // Edits to structure regenerate; comments marked @keep survive. // Written by generators/districts.ts from districts.config.json; edit the config, not this file. ``` A later run that omits the flag keeps the line, as it keeps `@keep` comments, so the generator stamps it once; `--banner ""` removes it. The same option is `banner` on `@flow-as-code/core`'s `codegen()`, and `conformance/codegen/` holds the cases. A `.flow.tf` carries no banner, and the flag is refused with `--to tf`. The TypeScript codegen writes is left unchanged by Prettier 3 at this repository's settings (`.prettierrc`: double quotes, trailing commas, print width 100, `arrowParens: always`), so a consumer on those settings need not list the companions in `.prettierignore`. Lines are measured as Prettier measures them (`packages/core/src/width.ts`, a port of its display width: CJK and fullwidth text two columns, combining marks none), so a Japanese or Korean prompt is a fixed point too. Three emoji classes are known exceptions, measured differently from Prettier: a tag-sequence flag (England, Scotland, Wales), a ZWJ pair that is not a registered sequence, and a skin-tone modifier after a base that takes none; a prompt carrying one of those at the print width is reformatted by Prettier, and `.prettierignore` is the answer there. `packages/core/src/roundtrip.test.ts` formats every roundtrip case's output and asserts it comes back byte-identical, and `src/codegen.test.ts` does the same for every export golden. A consumer on other settings (a print width of 80, single quotes) formats the companions like any other file and the next `codegen` writes them back; the round trip through `synth` does not read formatting, so nothing is lost either way, but the diff is noise, and `.prettierignore` with `**/*.flow.ts` is the quiet option there. `flow-cli convert --to ts|tf` switches which companion a document has. It writes the new one from the document, carrying `@keep` comments across (as `# @keep` in HCL and `// @keep` in TypeScript), restamps the document's `meta.sourceKind` and `meta.sourceHash`, and deletes the old companion unless `--keep-old`. It refuses when the new companion already exists, and when the old one has edits the document does not hold (its hash is not the document's `meta.sourceHash`): run `synth` on it first, or pass `--force` to discard them. What does not carry is printed as a `note:` on stderr: converting to `tf` binds only what `--address-map` gives (the rest is `null` under a TODO comment) and writes `instance_id = var.connect_instance_id` with no tags or lint settings; converting to `ts` drops the `.flow.tf`'s bindings, `instance_id`, tags, state and lint settings, which TypeScript has no place for. ## synth `flow-cli synth ` reads a `.flow.tf` in process, parsing and never executing it, and writes the one document its resource holds with `meta.sourceKind` `tf`. A refs key no action uses, and a resource address in a reference field that is read as its key, are warnings on stderr. Everything below is about `.flow.ts`. `flow-cli synth [--out ]` evaluates a TypeScript builder file and writes one `.flowdoc.json` per flow it exports, into `--out` or, by default, next to the source file. A flow is any exported `Flow` instance and the result of any exported zero-argument function that returns one. Output is byte-stable (`@flow-as-code/core`'s canonical serializer) and carries `meta.generator` (`cli@`) and `meta.sourceHash` (`sha256:` of the source file bytes). A builder file does not need `@flow-as-code/core` installed beside it. The child resolves that import normally first, so a copy in the file's own `node_modules` wins; only when nothing resolves does it fall back to the copy flow-cli itself is running against. That is what lets a folder holding nothing but FlowDocs and the `.flow.ts` files `codegen` wrote next to them synth at all. The fallback covers `@flow-as-code/core` and nothing else, so a builder file cannot reach the rest of `@flow-as-code/cli`'s dependencies through it. The builder file itself is evaluated as ES module source whatever the enclosing `package.json` says, including an explicit `"type": "commonjs"` (which is what `npm init -y` writes). That is what codegen emits and the only module system that can import `@flow-as-code/core`, which ships ES modules only. The override covers that one file and not the project: a module the builder imports keeps its project's module system, so in a CommonJS project a helper `.ts` that imports `@flow-as-code/core` still fails, and synth says so by name. Set `"type": "module"` in the nearest `package.json`. The builder file never runs in the CLI process. It runs in a child node with tsx registered via `--import`, a stripped environment (PATH plus a private temp dir for tsx), and cwd pinned to the source directory. Where the runtime has the stable `--permission` flag (Node 22.13+), Node's permission model is enabled: file writes are denied everywhere except tsx's private temp dir (the parent process writes the FlowDocs), and child processes are denied. File reads are not jailed, and on Node 22/24 the permission model does not cover network access at all; see the header comment in `src/synth.ts` for exactly what is and is not enforced, with citations. ## diff `flow-cli diff --instance ` answers whether what is checked in matches what is deployed. Every `*.flowdoc.json` in `` is validated and paired with the live flow or module of the same kind and name, where the live name is the slug `export` derives from the console name. The live side goes through the same exporter, so it carries tokens rather than ARNs, and the two documents are compared as canonical JSON with `layout` and `meta` removed: positions and provenance are not differences anyone deploys. One line per local document, in path order, then a unified diff of the canonical JSON under each changed one: ``` flows/appointment-line.flowdoc.json: changed --- local/flows/appointment-line.flowdoc.json +++ live/appointment-line @@ -13,7 +13,7 @@ ... flows/recording-consent.flowdoc.json: unchanged flows/callback-offer.flowdoc.json: missing-live ``` Live flows with no local document are not reported. The same two states always print the same bytes, so the output can be compared across runs. A live flow that has never been published has no published content, so the exporter reads its saved content through the `$SAVED` alias, the same way `export` does. The line says so, `flows/draft-line.flowdoc.json: unchanged ($SAVED)`, and a diff under it is labelled `+++ live/draft-line:$SAVED`. Unchanged against a draft means the draft matches, not that anything is deployed. Exit 0 when nothing differs, 1 when any document is changed or has no live counterpart, and 2 when the comparison itself failed: bad arguments (a missing `--instance` or an unknown flag included, where every other command exits 1), the SDK not installed, the instance unreadable, or a matched live flow the exporter could not convert (that document prints as `error:` with the reason, and the run still reports the rest). ## export `flow-cli export --instance [--out ] [--author ts|tf] [--no-codegen] [--on-error abort|collect]` reads every flow and module in the instance and writes `.flowdoc.json` plus its companion for each into `--out`, which defaults to the working directory and is created if needed. The companion is `.flow.ts`, or `.flow.tf` with `--author tf`, whose `refs` values are `null` until bound. A document whose other companion is already in `--out`, or whose existing companion does not parse, is reported as a failure rather than given a second companion or overwritten. `--no-codegen` writes the documents only. References come out as tokens, never ARNs, and the name is the slug the exporter derives from the console name (`Appointment Line` becomes `appointment-line`). A flow and a module that slug to the same name would share a file name; the second one is reported as a failure rather than overwriting the first. Each pair is written the way `synth` and the studio write it: the document carries `meta.sourceHash` of the generated source, so the watch engine sees an exported pair as in sync, and an existing companion is read first so its `@keep` comments (and a `.flow.tf`'s carried values) survive, as with `codegen`. A flow that has never been published is read through the documented `$SAVED` alias and marked as such in the summary. stdout carries one line per document written and a closing count; stderr carries the exporter's warnings (`warning: ...`, for content it had to skip or could not fully represent) and one `failed: (): ` line per flow it could not export. ``` appointment-line (flow): appointment-line.flowdoc.json, appointment-line.flow.ts recording-consent (module): recording-consent.flowdoc.json, recording-consent.flow.ts Exported 2 of 3 to /work/flows ``` `--on-error` defaults to `collect`, which writes everything that exported and reports every failure at once. `abort` stops at the first failure and writes nothing. The default is collect because a fresh instance always holds the stock "Sample Lambda integration" flow, which calls a Lambda in an AWS-owned account that the instance's Lambda list cannot return; that is an unknown-ARN hard error by design (see the Export section of `packages/core/SPEC.md`), and aborting on it would make a first export of any new instance write nothing. Either mode exits 1 when any flow failed. `--on-error` governs flows only. The inventory listing that precedes them (flows, modules, queues, hours, prompts, Lambda functions, bots) fails in either mode with the SDK's own message, an `AccessDeniedException` naming the missing action for instance, and nothing is written. ## simulate `flow-cli simulate --instance [--resource-map ] [--format junit|json] [--out ]` runs a scenario suite through the instance's TestCase operations (https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html) within the documented limits, 5 concurrent, 100 in flight, 5 minutes per scenario, and writes a report. `` is one scenario file, or a directory. A directory contributes every `scenario.json` and `*.scenario.json` directly in it and one level down, which is the layout of `conformance/simulate/` (one `scenario.json` per case directory); other files are ignored. Everything that can fail offline fails before the instance is touched, and all at once. Every scenario is validated against `scenario-0.1.schema.json`, the byte copy of `conformance/schema/scenario-0.1.schema.json` this package ships, then against the cross-field rules the schema cannot express, and every `${cdref:...}` token in the suite is resolved through `--resource-map`, a JSON object from reference to ARN in the same shape `render` takes, with the same three key forms ("Reference map keys" above). A suite with one broken scenario or one unmapped reference creates no test case. The report goes to stdout, or to `--out`, in which case the path is printed instead. `--format junit` (the default) is the `` document CI systems ingest; `--format json` is the stable `flow-simulate-report/0.1` document. Scenarios that did not pass are also described on stderr, one block per scenario with the failed observations, so a CI log says why without opening the report. Exit 0 only when every scenario `PASSED`. `FAILED`, `TIMED_OUT` (the harness stops the execution when the 5 minutes are up), `STOPPED`, and `ERRORED` (an API error for that scenario) each exit 1, and the summary line says how many of the suite did not pass. ### Dry run `flow-cli simulate --dry-run [--resource-map | --address-map ]` checks the same suite against a set of FlowDocs (directories or files, read together as one set: a scenario runs across a flow and the modules it calls, wherever those live, so `flows/ seasonal/` is one set here where `lint` and `emit` would keep them apart) with no instance, no credentials and no SDK: it is what a unit test or a CI job without an AWS account runs, and what to run before paying for a live run. Exit 0 means nothing offline says a scenario cannot pass; any problem exits 1 with every problem listed, one line per problem as `: : `. What it checks, after the validation the live run also does: - The entry point's `${cdref:flow:...}` names a flow in the set. - Every token resolves. A resource an `expect-lambda`, `expect-transfer`, `expect-hours-check` or `expect-lex` waits on, and the production resource a substitution replaces, must be referenced by some document in the set, or the event can never fire. Any other token (a substitute, a queue named by `expect-queue`) must be referenced by the set or keyed in the map. Only keys are read, so `--address-map` (the file `emit` takes) serves as well as `--resource-map`, in the same three key forms; a scenario whose tokens the set itself references needs no map at all. - Every `expect-prompt` is a text some block in the set plays: `Text`, the spoken words of `SSML`, and the messages of a loop, in any document of the set, a module included. `contains` is a case-insensitive substring match. `similarTo` is loose, since the service's similarity threshold is not published: half the expectation's words must appear in one text. A prompt that reads `$.Attributes.` is heard with the value the scenario starts the contact with or asserts with `Equals`, so "Welcome back, Mrs. Alder" matches `Welcome back, $.Attributes.callerName.` when the scenario asserts that name; an attribute the scenario says nothing about splits the prompt, and the expectation has to fit one literal part. - Every `send-dtmf` answers the nearest `expect-prompt` before it, which must be played by a `GetParticipantInput` (then the key must be one its conditions branch on) or a `StoreUserInput` (any key). A key sent after a prompt no keypad block plays, or before any prompt, is a problem. What it does not do, so a clean dry run is not a passed scenario: it does not execute Lambdas, evaluate conditions on attribute values, follow the contact through branches (every text in the set counts, whichever branch plays it), read recorded prompts (a `PromptId` plays audio whose words are not in the document, and a miss says how many there are), or model the speech-to-text transcript a voice `MessageReceived` is matched against, which drops punctuation and may spell numbers out. `--format`, `--out` and `--instance` belong to the live run and are refused with `--dry-run`, `--format junit` included: the dry run has no report to format. Two paths holding a document of the same kind and name are refused rather than silently merged. `conformance/simulate/dry-run/` is the contract: a two-document set, a map, and one case per thing the check can say, each with the exact problems it must report. docs/08-simulate.md puts the two runs side by side. A scenario's `expect-queue` takes the queue's token (`queue`) as well as its console name (`name`). The token form compiles to an Assert on `$.Queue.ARN`, resolved through the resource map like every other token, so the map must hold the queue's ARN rather than a bare id for it. It has not yet been executed against an instance: the `name` form was, on 2026-09-01, and `tasks/C14-simulate-offline-checks.md` records the live run the token form waits on. ## watch (library) `@flow-as-code/cli/watch` exports `createWatcher(dir)`, the engine behind studio sync (A11). It watches one directory (non-recursive) and pairs each `.flowdoc.json` with its companion, `.flow.ts` or `.flow.tf`, by base name. On a companion change it re-reads the companion (a `.flow.ts` in the same sandboxed child `synth` uses, a `.flow.tf` parsed in process) and rewrites the doc, stamping `meta.sourceKind`, guarded by `meta.sourceHash` (docs/01-flowdoc-spec.md, invariant 3): if the doc on disk was edited externally since the watcher last wrote or observed it, and that edit does not carry the previous companion content's hash, the pair is dirty on both sides. The watcher then emits `conflict` and writes nothing. A pair that is already diverged when the watcher starts also gets a `conflict` rather than an overwrite. A document another tool has already restamped for the companion's current content (`codegen`, `convert`) is in sync, not a conflict. A name with both a `.flow.ts` and a `.flow.tf` is ambiguous: every change to either is an `error` naming both, nothing is synced, and removing one syncs from the other. The re-synth uses the same resolution rules `synth` documents above, so a watched directory needs no `node_modules` of its own for the builder files in it to resolve `@flow-as-code/core`. Events: `synced {sourcePath, sourceKind, docPath, name, warnings?}`, `conflict {sourcePath, sourceKind, docPath, name, reason}`, `error {path, message}`, plus a `ready` convenience event after the initial scan settles. `warnings` are what reading a `.flow.tf` noticed without refusing it: a `refs` key no action uses, a resource address in a reference field read as its key. A `.flow.tf` the reader refuses is an `error` carrying the contract's code and the file, line and column. A ts file whose flow names do not include the file's base name syncs only when it exports exactly one flow, and a `.flow.tf` only when its resource's `name` is the file's base name; otherwise the watcher emits `error` for that pair. A change that leaves the companion byte-identical to the last synced content is normally silent (it is how the watcher recognizes its own writes), with one exception: when the preceding change ended in `error`, the same no-op emits `synced`. Undoing a broken edit is the ordinary way to fix one, and consumers latch the error, so without that event the studio's out-of-sync badge outlived the state it reported and cleared only on some later, unrelated edit. `noteWrite(name, {sourceContent, docContent})` records a pair another part of the same process just wrote as the clean baseline, so the studio bridge's own writes are not read back as external edits. Pass the exact bytes written, and call it after both files are on disk. ## studio `flow-cli studio [dir] [--port ]` serves the visual editor over a directory of FlowDocs and their companions (`.flow.ts` builder files or `.flow.tf` resources), with live sync in both directions. It prints the URL to open; `dir` defaults to the working directory and the port defaults to a free one. A document with no companion beside it gets one written before the server starts (the kind its `meta.sourceKind` names, else `.flow.ts`), and the command prints a line per file: ``` wrote appointment-line.flow.ts from appointment-line.flowdoc.json ``` That is the same `codegen` the canvas runs on save, so the bytes are the ones the first save would have written, and the document is stamped with the `meta.sourceHash` of the source just generated. Without it a directory holding only documents (exported from an instance, copied out of `conformance/`, sent by a colleague) had no builder file to edit, so the loop this command exists for could not start there. An existing builder file is never overwritten. A document `codegen` refuses is named and skipped, and the rest of the directory still opens: ``` skipped broken.flowdoc.json: broken.flowdoc.json is not a valid FlowDoc: ... ``` A builder file the watcher cannot synth is reported on the same stream: ``` error appointment-line.flow.ts: Evaluating /w/appointment-line.flow.ts threw: Transform failed with 1 error: /w/appointment-line.flow.ts:101:16: ERROR: Unexpected ";" ``` Stack frames inside `node_modules` and node's own internals are dropped from that message. A transform failure carries ten frames of esbuild and stream internals under a line that already names the file, line and column, and the studio's badge shows only the first line for the same reason. Frames in files you wrote survive, because a runtime throw inside a flow needs them. There is no switch that restores the dropped frames. `node_modules` has to be a whole path segment of the frame's own location for the frame to count as vendor code, so a project under a directory called `node_modules-sandbox`, or a function named `loadNodeModules`, keeps its frames. A pair assembled by hand is a different case and still reports a conflict: the document has to carry the `meta.sourceHash` of the source next to it, or the watcher has no evidence the two ever agreed. `flow-cli synth .flow.ts` writes that stamp; removing the stale side works too. The server binds `127.0.0.1` and only `127.0.0.1`. This process writes files you own, so a bridge reachable from the network would be a remote file writer; the bind address is not configurable for that reason. Binding loopback is not by itself protection against DNS rebinding (a page on the internet can resolve its own hostname to 127.0.0.1), so the `Host` header is checked as well and a request addressed to anything else is refused. No CORS headers are sent, and the server makes no outbound connections. What it serves: the built studio assets from the installed `@flow-as-code/studio` package, and a small JSON API under `/bridge`. If the studio package is present but has not been built, the command says so and names the build command. The API is these eight routes and nothing else; anything else under `/bridge` answers 404 with the method and path it was given (`src/bridge/server.ts`): | Route | Method | What it does | | ----------------------------- | ------ | ---------------------------------------------------- | | `/bridge/info` | GET | Served directory, version, and the session token | | `/bridge/docs` | GET | The documents in the directory and each companion | | `/bridge/docs` | POST | Create a document and its companion; 409 if taken | | `/bridge/docs/` | GET | One pair: the FlowDoc and which companion it has | | `/bridge/docs/` | PUT | Save that pair; 409 when the pair diverged | | `/bridge/docs//resolve` | POST | Settle a refused save by choosing a side | | `/bridge/export` | POST | Write an emitted file map under the served directory | | `/bridge/events?cursor=` | GET | Long-polled watch events from `` on | Every request to `/bridge` and to the document itself carries the session token, either as the `token` query parameter (which is how the printed URL opens the page) or as the `x-flow-studio-token` header (which is how the page's own calls send it). Subresources are served without one so the token never appears in an asset URL. `src/bridge/forgery.test.ts` holds this contract, along with the `Sec-Fetch-Site` and `Origin` checks described above. `POST /bridge/export` is how the studio's export buttons reach disk: the studio emits the file map (the same emitters this CLI's `emit` and `render` use) and this process writes it under the served directory. Paths are checked against the protocol's path rule and then again by resolving them and requiring the result to be inside that directory. Nothing is paired and no event is published: emitted terraform and CDK files are output, not documents. Saving from the canvas writes both halves of the pair: the FlowDoc, and its companion regenerated (`.flow.ts` with `codegen`, `.flow.tf` with `@flow-as-code/hcl`), passing the existing file so `@keep` comments survive, and for a `.flow.tf` its `refs` bindings, `instance_id`, tags and lint settings too. The document is stamped with `meta.sourceHash` of the source just written and `meta.sourceKind`, which is what keeps the watcher from reading the save as a divergence. A save never writes a second companion: the kind is the one on disk, else the document's `meta.sourceKind`, else `ts`. `POST /bridge/docs` takes `{doc, sourceKind}` and writes the document and the companion it names, answering 201 with the write result, or 409 without writing anything when the document or either companion already exists. The bridge protocol is version 2: payloads carry `sourceKind`, a `.flow.tf`'s `lint.disable` list as `lintDisable`, and a write result names its companion as `sourcePath` and `sourceText`. Conflicts are never merged. A save whose builder file has changed since the document was generated from it is refused with 409 and both sides are offered in the studio's dialog; so is a pair the watcher finds diverged on disk. Every write stays refused until the choice is made. --- # @flow-as-code/core ``` npm i @flow-as-code/core ``` Apache-2.0, Node 22.12 or newer. The six packages version together and are released as a set, so keep them at matching versions. Site and docs: . This package on npm: . The engine. Typed builder and synthesizer, FlowDoc interchange (docs/01-flowdoc-spec.md), lint engine, codegen (FlowDoc to idiomatic TypeScript), export from live instances, and the simulate scenario format, runner, and reporters. No CDK dependency; references are abstract `${cdref:type:name}` tokens materialized by `@flow-as-code/cdk` (CloudFormation tokens), `@flow-as-code/tf` (templatefile variables), or a resource map (`flow-cli render`). ## Export `exportInstance(client, options)` turns a live Amazon Connect instance into FlowDocs, and TypeScript alongside them when asked. `exportFlow(content, reverseMap, options)` does one flow from its Flow language JSON. An ARN with no entry in the instance inventory is a hard error listing every unknown ARN at once. The client is an interface, so the whole path is testable offline against the recorded fixtures in `conformance/export/`. `createConnectInventoryClient` adapts an `@aws-sdk/client-connect` client to it and owns pagination and the 2 rps throttle budget. It loads the SDK with a dynamic import, so importing `@flow-as-code/core` never requires the optional peer dependency. ## Simulate There is no Amazon Connect API called Simulate. The operations are the TestCase family, and a scenario has to be created on the instance as a published test case before it can run. See SPEC.md, which records the corrections, and `src/simulate.ts`, which cites the doc URL for every shape. A scenario is authored as an ordered list of steps (`conformance/schema/scenario-0.1.schema.json`); `compileScenario` lowers it to the Connect Testing language graph that `CreateTestCase` takes. `dryRunScenario(scenario, docs, { resourceMap })` is the offline half: with no instance it holds a scenario to the FlowDocs it will run through (the entry flow is in the set, every token is referenced or mapped, every expected prompt is a text a block plays, every key pressed is one a menu takes), with `conformance/simulate/dry-run/` as its contract; `flow-cli simulate --dry-run` is this over files. `runScenarios` owns the create, execute, poll, collect, delete lifecycle within the documented limits: 5 concurrent, 100 in flight including the running 5, and a 5-minute hard cap per scenario. `junitReport` and `jsonReport` are byte-deterministic for the same run. Live paths are gated on `FLOW_TEST_INSTANCE_ARN` and, for simulate, `FLOW_TEST_SIMULATE=1`. `npm test` never touches an AWS account. The export half is read-only (`List*` and `Describe*` only); everything that writes takes its own opt-in, including `@flow-as-code/cdk`'s deploy test (`FLOW_TEST_DEPLOY=1`), which `npm run test:integration` also runs. ### What CI runs and what an operator runs The `sandbox integration` job in `.github/workflows/integration.yml` sets `FLOW_TEST_INSTANCE_ARN` and `FLOW_TEST_DEPLOY` only, so it covers exactly two things: the whole-instance live export in `src/integration.test.ts`, and `@flow-as-code/cdk`'s A07 live deploy. It runs only on a push to `main` or a `workflow_dispatch`, and only when the `CONNECT_SANDBOX_ENABLED` repository variable is set. The other two `describe` blocks in `src/integration.test.ts`, the simulate suite and the lossless export of the deployed demo flow, are gated on `FLOW_TEST_SIMULATE` and `FLOW_TEST_RESOURCE_MAP` and skip in that job by design. Both need a superset stack deployed first (the demo flow's hours, queues, Lambda, and the Lambda's integration association) and a resource map written from that stack's outputs, neither of which a stateless CI job can produce. They stay an operator run; `tasks/A06-export-and-simulate.md` records the procedure, the exact command, and the results of each session. --- # @flow-as-code/cdk ``` npm i @flow-as-code/cdk ``` Apache-2.0, Node 22.12 or newer. Peers on `aws-cdk-lib` and `constructs`, both optional so a CLI-only install does not fetch them. Site and docs: . This package on npm: . CDK binding for `@flow-as-code/core`. Two exports, plus a scaffold generator on a subpath: - `TokenBinder`: an interface you implement, mapping reference names to construct attributes (`queue.attrQueueArn`, `fn.functionArn`, a Lex alias ARN). `@flow-as-code/core`'s `materializeWithBinder` inserts the returned strings byte-for-byte, so CDK tokens pass through and CloudFormation resolves them per account at deploy time. No resource map, no literal ARNs in this path. - `FlowSet`: a construct consuming a directory of `*.flowdoc.json` files (or `FlowDoc[]`) plus a `TokenBinder`. Flows become `AWS::Connect::ContactFlow` (type from `connectType`), modules become `AWS::Connect::ContactFlowModule`, created in dependency order. References between documents in the set resolve without the binder (see below). ## Usage ```ts import { FlowSet } from "@flow-as-code/cdk"; new FlowSet(stack, "Flows", { instanceArn: instance.attrArn, source: "flows/", // or FlowDoc[] binder: { queue: (name) => queues[name].attrQueueArn, hours: (name) => hours[name].attrHoursOfOperationArn, lambda: (name) => fns[name].functionArn, lex: (name) => lexAliases[name].attrBotAliasArn, prompt: (name) => prompts[name].attrPromptArn, // flow?(name) and module?(name, alias): only for a flow or module that is // not in this set, see "References inside and outside the set". }, }); ``` ## References inside and outside the set A reference that names a document in the set is resolved by `FlowSet` itself, the way `@flow-as-code/tf` and `@flow-as-code/hcl` resolve their own documents; the binder is not consulted for it: - `${cdref:module:name@alias}` whose module is in the set becomes the alias ARN of the `AWS::Connect::ContactFlowModuleAlias` the construct manages (see the versioning model below). - `${cdref:flow:name}` whose flow is in the set becomes that flow's ARN (`Fn::GetAtt` at its `AWS::Connect::ContactFlow`), with an explicit dependency. This is what a main line that points `UpdateContactEventHooks` at a whisper or hold flow in the same directory needs, and `TransferToFlow` to a flow in the set works the same way. Documents are created in dependency order over both kinds of reference, and the order is stable, so the template is byte-identical however the files were read. A set whose flow references form a cycle (two flows transferring to each other, a hook pointing back at its caller) fails at synth with the cycle spelled out, `Flow reference cycle: day-line -> night-line -> day-line`, because each flow would need the other's ARN and one CloudFormation template cannot create that; at deploy it would surface as a `Circular dependency` naming logical IDs. Deploy one side from another stack and bind it through `flow()`. A reference that names a document outside the set goes to the binder's optional method: - `flow(name)` returns the ARN of a contact flow managed elsewhere. - `module(name, alias)` returns the alias ARN of a module managed elsewhere, at the alias the token pins (`live` when it pins none), so the same document binds to a different module alias per environment, from a map or an `Fn.importValue`. Without the method, synth fails naming the token, the document and both remedies (add the document to the set, or implement the method). A `Lazy.string` is a fine return value: `FlowSet` materializes content in its constructor, so a binder pointing at a flow another `FlowSet` in the same stack manages defers the lookup with `Lazy.string({ produce: () => other.flows.get(name)!.attrContactFlowArn })`, and CDK resolves it at synth, when both constructs exist. The scaffold writes `flow` and `module` methods only for names the set does not hold. ## The scaffold (`@flow-as-code/cdk/scaffold`) Writing that stack by hand starts with the same file every time, so `cdkScaffold({ docs, source })` generates it: a `FlowStack` constructing a `FlowSet` over the FlowDoc directory, and a `TokenBinder` carrying one `TODO` per reference type the documents actually use, with the referenced names listed. Types the set does not use still get a method, because the interface requires them, but theirs throws instead of carrying a TODO. The subpath exists because two tools emit this file and must emit it identically: `flow-cli emit --target cdk` and the studio's CDK export button. It is pure and imports no node builtin, so the studio can bundle it for a browser; `source` is the FLOW_DOCS expression (`.`, `..`), which is the one thing that needs a path calculation, and `sourceForDepth` computes it where node's `path.relative` is unavailable. The emitted file resolves that expression against itself with `new URL(source, import.meta.url)`, so the stack reads the same directory whatever the CDK app's working directory is. Output is byte-stable and independent of the order the documents were read. ## Peer dependencies `aws-cdk-lib ^2.267.0` and `constructs ^10.8.1`, both optional. A project that has them pinned below that floor gets an ERESOLVE from npm 7 and later rather than a silent mismatch; a project that has neither installs neither. The floor is deliberate: `FlowSet` builds `CfnContactFlowModuleVersion` and `CfnContactFlowModuleAlias` (see the versioning model below), which are recent additions to `aws-cdk-lib/aws-connect`, and 2.267 is the version this package is developed and tested against. Lowering it means finding the release that introduced both L1s and running the `aws-cdk-lib/assertions` tests against it, not just widening the range. They are optional because the CDK entry is not the only entry. `@flow-as-code/cli` and the studio reach this package for `@flow-as-code/cdk/scaffold` alone, which imports nothing but `@flow-as-code/core`: `aws-cdk-lib` appears in that module as a line of the stack source it generates, not as an import. Non-optional peers are auto-installed, so with these declared as ordinary peers every `npm install @flow-as-code/cli` would fetch 166 MB of CDK that nothing in that install loads. Importing `FlowSet` or the binder still needs both packages present, and `tests/packaging.test.ts` holds the scaffold subpath to its one bare import. The cost of optional is that npm says nothing at install time, so the package says it itself. Importing `@flow-as-code/cdk` with `aws-cdk-lib` absent throws: ``` @flow-as-code/cdk needs the CDK at runtime, but aws-cdk-lib is not installed. It and constructs are optional peers so that a CLI-only install stays small, which means npm does not warn about them at install time. Install them with: npm install aws-cdk-lib@^2.267.0 constructs@^10.8.1 ``` The entry checks for the peer before it loads `FlowSet`, so this replaces the bare `ERR_MODULE_NOT_FOUND` that used to come out of `dist/flow-set.js`. The `./scaffold` subpath is unaffected and imports with no CDK installed. `src/index.test.ts` runs the entry in a directory with no `node_modules` and asserts the message, including the version floor read from `package.json`. ## Versioning model - Modules are versioned. Each module gets an `AWS::Connect::ContactFlowModuleVersion` whose logical ID embeds a hash of the module's canonical content, so a content change publishes a new immutable version, plus an `AWS::Connect::ContactFlowModuleAlias` per alias name the doc set pins (default `live`) with a stable logical ID that repoints to the new version. Flows reference the alias ARN, so repointing changes no flow content. - Full flows are not versioned; content updates in place. Connect's `CreateContactFlowVersion` states "This API only supports creating versions for flows of type Campaign." (https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateContactFlowVersion.html, verified 2026-08-31) ## Validation `FlowSet` refuses, at synth time: duplicate document names, `kind`/`connectType` mismatches, flow and module reference cycles (named), and any document failing a hard `@flow-as-code/core` lint rule (`no-literal-arn`, `no-unresolved-token`). Binder gaps, including a module or flow that is neither in the set nor bound, fail with an error naming the token, the document, and the missing method. No instance/queue provisioning constructs here; users bring their own or use L1s. ## Testing Offline tests assert on synthesized templates with `aws-cdk-lib/assertions`. The live-deploy test in `src/integration.test.ts` runs only when both `FLOW_TEST_INSTANCE_ARN` and `FLOW_TEST_DEPLOY=1` are set, deploys the demo flow plus a versioned module to that instance through the AWS CLI, and tears the stack down. It writes to the account, so naming an instance is not enough on its own: use an empty sandbox. That test has run green against a live Connect instance (2026-09-02). The test asserts only that the deploy exited zero, and it deletes the stack in its `finally`, so the content check was a separate step: the same synthesized template was deployed again and left up, and its flows were read back with `DescribeContactFlow` and `DescribeContactFlowModule` and compared against what the FlowDocs materialize to. That comparison is structural rather than byte-for-byte, because deploying resolves each `${cdref:...}` reference to a real ARN: every other position matched exactly, and the reference positions were checked against the stack's own resources instead, each resolving to a resource of the right kind and the module reference resolving to the alias rather than the version. The stack needs no CDK bootstrap: it is asset-free and uses `CliCredentialsStackSynthesizer`. `tasks/A07-flow-cdk.md` records both. --- # @flow-as-code/tf ``` npm i @flow-as-code/tf ``` Apache-2.0, Node 22.12 or newer. Site and docs: . This package on npm: . Implements docs/03-tf-emitter.md. Deterministic HCL emission, `terraform validate`-clean when the address map is complete, loud TODO placeholders when it is not. Never emits literal ARNs, provider blocks, or backend config. One-way: FlowDoc in, HCL out, and this output is not read back. `@flow-as-code/hcl` writes and reads the provider-native shape (a resource per flow with its actions as blocks), for the `flow-as-code/flowascode` provider; docs/06-terraform-provider.md says when to pick which. ## Use ```ts import { emitTf, writeTf } from "@flow-as-code/tf"; const { files } = emitTf([doc], { addressMap: { "queue:front-desk": "aws_connect_queue.front_desk.arn" }, }); writeTf([doc], "infra/flows", { addressMap }); ``` `emitTf` is pure: documents in, a relative path to content map out, sorted by path and byte-identical across runs. `writeTf` is the filesystem wrapper; it creates directories, overwrites the files it emits, and touches nothing else in the target directory. `@flow-as-code/tf/emit` is the same emitter without the filesystem half. The package index re-exports `writeTf`, which imports `node:fs`, so a browser bundle (`@flow-as-code/studio`'s Terraform export) imports the subpath instead. ## Output | File | What it holds | | ------------------------- | ------------------------------------------------------------------------------------------------------------- | | `flows/.flow.tftpl` | Deployable Flow language JSON, each reference replaced by a `templatefile` interpolation | | `flows.tf` | `aws_connect_contact_flow` / `aws_connect_contact_flow_module`, plus awscc module version and alias resources | | `flow_refs.tf` | `locals` holding the terraform address behind every reference | | `variables.tf` | The Connect instance id variable, unless `instanceIdExpression` supplies one | | `versions.tf.example` | Required providers and versions, as an example; not loaded by either tool | Provider configuration, credentials, and backend configuration are yours. The emitter writes none of them, by rule. ## References Every `${cdref:type:name}` token becomes a template variable named `__arn`, hyphens as underscores, with a module alias folded in: `queue_front_desk_arn`, `module_survey_prod_arn`. Addresses come from three places, in this order: 1. **The document set itself.** A `${cdref:module:x@prod}` whose module `x` is emitted here resolves to `awscc_connect_contact_flow_module_alias.x_prod.contact_flow_module_alias_arn`; a `${cdref:flow:y}` whose flow `y` is emitted here resolves to that flow. An address map entry for one of these is ignored, and the emitted file says so on the line above. 2. **The address map.** Keys are a token (`${cdref:queue:front-desk}`), the token body (`queue:front-desk`, `module:survey@prod`), or the variable name (`queue_front_desk_arn`). Values are HCL address expressions. A literal ARN is refused, as is anything that would comment out or overrun its line. Entries matching no reference in the set are left out, so one shared map can serve several flow sets, and their keys come back in the result's `unusedMapKeys` (the CLI warns on each, or refuses under `--strict`); an entry a reference reaches but the set resolves itself counts as used. 3. **Nothing.** The entry becomes `TODO_MISSING_ADDRESS__` with a `# TODO` naming the reference. That is an undeclared reference, so `terraform validate` and `tofu validate` fail loudly rather than an apply writing a broken flow. The result's `unbound` lists each such key with the documents that make it; the CLI exits 0 on this target all the same, since the placeholder is the loud form. Addresses of resources the set emits itself live in a per-document local (`merge(local.flow_refs, { ... })`) rather than in the shared map. Putting them in the shared map makes the map depend on the resources rendered from it, and both tools report that as a dependency cycle. Two flows that reference each other are still a cycle, because it is one. ## Escaping The `.tftpl` body is JSON containing caller text, and caller text legitimately contains `${` and `%{`, the two sequences HCL reads as template introducers. Every one of them is escaped (`$${`, `%%{`) and only the emitter's own reference interpolations are left live. The substitution runs through unique sentinels chosen so they cannot collide with the document, so text that looks like a sentinel is caller text and stays caller text. `conformance/emit-tf/hostile-text` holds the fixture, and the gated tests render it with a real `tofu` and compare against a reference implementation of the template grammar. ## Versions and aliases Flow versioning is not available: `CreateContactFlowVersion` "only supports creating versions for flows of type Campaign" ([API reference](https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateContactFlowVersion.html)). Flows update their content in place. Modules get `awscc_connect_contact_flow_module_version` and `awscc_connect_contact_flow_module_alias`, which is how a caller pins the module content it invokes. A version snapshots content at create time, so a module content change needs the version resource replaced for aliases to serve it. The aliases a module gets are the ones flows in the same set invoke it through (`${cdref:module:survey@prod}` gives `survey` a `prod` alias). A module nothing in the set references gets its resource and a version, and no alias: the emitter cannot know what a root elsewhere will bind. To release such a module on its own, declare the aliases it publishes, `moduleAliases: { survey: ["live"] }` (`flow-cli emit --module-alias module:survey@live`, repeatable); the alias resource is then written exactly as for an invoked module, and another root binds `module:survey@live` through its address map. A module not in the set, or an alias that is not a slug, is refused. `conformance/emit-tf/module-release` is the case. ## Tests `npm test` runs the goldens in `conformance/emit-tf` and the unit tests. The tests that run a real `tofu init`, `tofu validate`, `tofu fmt`, and `tofu console` need the network and a few minutes on a cold provider cache, so they are gated behind `RUN_TOFU_VALIDATE=1`: ```sh RUN_TOFU_VALIDATE=1 npx vitest run --project @flow-as-code/tf ``` CI runs them in the `emit-tf` job, and an ungated test asserts that the job still sets the variable. Regenerate goldens deliberately with `UPDATE_GOLDENS=1` and read the diff. --- # @flow-as-code/hcl ``` npm i @flow-as-code/hcl ``` Apache-2.0, Node 22.12 or newer. Browser-safe: no Node built-ins anywhere in its module graph, so the studio bundles it. Site and docs: . This package on npm: . HCL as a third view over FlowDoc (ADR-0007): a document can have a `.flow.tf` companion, a `flowascode_contact_flow` resource the `flow-as-code/flowascode` Terraform provider applies, instead of a `.flow.ts`. The mapping is the contract in `conformance/hcl/README.md`, which the provider implements too. This package is its TypeScript side. Two layers ship. The syntax layer is a lossless lexer and parser for the HCL native syntax, a formatter that writes exactly what `terraform fmt` and `tofu fmt` write, literal evaluation, and HCL string quoting. The document layer on top of it writes a FlowDoc as its companion (`fromFlowDoc`), reads a companion back (`toFlowDoc`), regenerates a companion without losing what the user wrote beside the document, and emits a set of documents as one configuration (`emitFlowascode`). ## Documents ```ts import { fromFlowDoc, toFlowDoc } from "@flow-as-code/hcl"; const tf = fromFlowDoc(doc, { bindings: { "queue:appointments": "aws_connect_queue.appointments.arn" }, fileName: "appointment-line.flowdoc.json", }); const { doc: back, sidecar, warnings } = toFlowDoc(tf, { fileName: "appointment-line.flow.tf" }); ``` `fromFlowDoc` writes the resource by the contract's rules 1 to 17: one `action` block per action, a typed sub-block wherever the catalog's shape accepts the action's `Parameters` and `generic` otherwise, reference fields as keys (`"queue:appointments"`) bound once in `refs`, an unbound key as `null` under a `# TODO` comment, and `position` only where the canvas moved an action off the auto-layout. The output is a `terraform fmt` fixed point. Given `previous`, the companion on disk, it carries what rule 24 names from it rather than from the document: `refs` values by key, `instance_id`, `tags`, `state`, `external_invocation_enabled`, `lint`, `provider`, `lifecycle`, `depends_on`, and comment lines marked `@keep` directly above the resource or an action. `toFlowDoc` reads a companion to its document in synth normal form and a sidecar of what the file holds beside it (`refs` bindings, `lint`, `tags`, the `instance_id` expression, kept comments, and every value it normalized). A reference field may hold a key, a JSONPath, the full token (`"$${cdref:queue:appointments}"`), or a resource address the contract's sugar table lists (`aws_connect_queue.appointments.arn`), which reads as the key plus a binding, so the next regeneration writes the key. Anything else is an `HclError` whose `code` is the contract's and whose `path` names the attribute (`action[set-queue].update_contact_target_queue.queue_id`); the message says which `refs` entry to write instead. A `refs` key no action uses is a warning, dropped on the next regeneration. The view holds what a canvas or a `.flow.ts` holds and no more: `content.Metadata`, which an exported flow may carry, is not written, and a fractional console position is written rounded. ## Emitting a set ```ts import { emitFlowascode } from "@flow-as-code/hcl"; const { files, unbound, unusedMapKeys } = emitFlowascode(docs, { addressMap: { "queue:appointments": "aws_connect_queue.appointments.arn" }, }); // files: flows.tf, outputs.tf, variables.tf, versions.tf.example // unbound: [{ key: "lambda:appointment-lookup", documents: ["appointment-line"] }, ...] // unusedMapKeys: map keys no reference in the set reaches ``` `emitFlowascode` is `flow-cli emit --target flowascode`: one resource per document in `flows.tf`, written as `fromFlowDoc` writes a companion, an `_arn` and an `_document_sha256` output per document in `outputs.tf` (the hash is of the resource's `flowdoc`, references still tokens, so a pipeline compares it across environments), with each `refs` map bound from the address map (keyed as `@flow-as-code/tf`'s is) or to what the set emits itself, and a version and alias resource for every module a flow in the set invokes by alias. A module nothing in the set references gets its resource alone, no version and no alias; to release one on its own, `moduleAliases: { greeting: ["live"] }` (`flow-cli emit --module-alias module:greeting@live`) writes the version and alias as for an invoked module, and `outputs.tf` adds `greeting_live_arn`, the value another root binds `module:greeting@live` to through its address map. What nothing binds is `null` under a `# TODO` comment, which the provider refuses at plan time; the result's `unbound` lists those keys with the documents that make them, and `unusedMapKeys` the map keys no reference reaches by any of its three forms, so a caller can refuse or warn (the CLI does both: an unbound key is an error without `--allow-unbound`, an unused key a warning, or an error under `--strict`). It refuses a literal ARN in the address map, a name that is not a slug, and two documents with one address, listing every problem on `EmitFlowascodeError.problems`. It never writes a `.flow.tf`. ## Parse, print, format ```ts import { format, parse, print } from "@flow-as-code/hcl"; const file = parse(text, "appointment-line.flow.tf"); print(file) === text; // every character is kept: whitespace, comments, escapes format(text); // the canonical layout, as terraform fmt writes it ``` `parse` returns a syntax tree over the file's full token array: every node records its first and last token, so it can be printed as written (`sourceOf`), located in an error, or have the comments around it found. A syntax error is an `HclError` whose message starts with `file:line:column`. `format` is a port of hclwrite's formatter, pass for pass: it indents each line by its bracket depth, sets the space between each pair of tokens by hclwrite's rules, and aligns the `=` of consecutive single-line attributes and their trailing comments. A line that opens a multi-line value is left out of the alignment run, as hclwrite leaves it. The package's conformance test holds `format` to the text of every Terraform file committed to this repository, and to `tofu fmt` itself when `RUN_TOFU_VALIDATE=1`. Terraform's fmt command also rewrites some legacy syntax (an interpolation-only `"${var.x}"` becomes `var.x`); `format` does not. ## Literals and strings ```ts import { evaluateLiteral, quote, unquoteLiteral } from "@flow-as-code/hcl"; evaluateLiteral(expr, file); // strings, numbers, booleans, null, tuples, objects quote("Balance: ${amount}"); // "Balance: $${amount}" ``` `evaluateLiteral` refuses anything that needs an evaluation context (a variable, a call, an interpolation) with the contract's `NON_LITERAL_VALUE` code. `quote` writes a string by the contract's rule 16: `\\`, `\"`, `\n`, `\r` and `\t` escaped, any other control character as `\uXXXX`, `${` and `%{` doubled so they stay literal, everything else raw. A lone surrogate has no UTF-8 spelling and is refused either way with `LONE_SURROGATE`. --- # @flow-as-code/studio ``` npm i @flow-as-code/studio ``` Apache-2.0, Node 22.12 or newer. A read-only build of this editor is deployed at https://flow-as-code.dev/studio/ with the demo flow loaded (docs/05-hosted-demo.md). Site and docs: . This package on npm: . Implements docs/02-studio-design.md. React Flow canvas over FlowDoc with a palette of modeled blocks, per-type inspector panels, typed ref pickers that reject literal ARNs, a refs sidebar with usage counts, worker-based inline lint, and a save gate enforced at every write path. Unknown Connect actions render as GenericBlocks with a read-only raw JSON inspector and rewireable transitions. The save gate is `assertSaveable` in `src/model/validate.ts`: it checks the hard lint rules (no-literal-arn, no-unresolved-token) and the FlowDoc schema from `conformance/`, and it runs inside `MemoryStore.write`, `DirectoryStore.write`, `BridgeStore.write`, the Export-as-download path, and every export target. The Save and Export buttons only reflect that rule; they read both halves of it, so a schema-invalid doc disables Save rather than failing at write time. ## The demotion invariant codegen falls back to `new GenericBlock({...})` for any Action a block class cannot reproduce exactly. That is right for unmodeled Connect actions and wrong as the result of a canvas gesture: the block silently loses typed authoring, and neither lint nor the schema nor the save gate can see it. `src/model/demotion.ts` answers "which Actions would codegen emit as GenericBlock?" by running `@flow-as-code/core`'s real codegen and reading the answer back, rather than restating the inverter rules. `src/model/mutations.ts` wraps every exported mutation in that check: if a block that existed before the edit would come out generic after it, the mutation throws `MutationRefused` naming the blocks and what to do instead. A block the gesture just created is exempt, because nothing was lost and lint already guides the user to wire it. `tests/mutationGuard.test.ts` enumerates the module's exports and fails if a mutation is not wrapped, so one added later is guarded by construction. Refusals are shown, never swallowed: `commit()` in `src/state/studio.tsx` turns both a `MutationRefused` and a plain `undefined` result into a transient `NoticeBar` message. Deleting a block that other blocks still point at is refused rather than repaired, because repairing means inventing a destination for someone else's transition; the message names the neighbours and the fix is to rewire those edges first. Local-first: the demo FlowDoc and the FlowDoc schema are baked in at build time, files open via the browser or the File System Access API where available, and the built bundle carries zero network dependencies (test-enforced). Persistence goes through the DocStore seam in `src/store/types.ts`. VS Code custom-editor packaging is a backlog item reusing this webview. ## What it publishes The tarball is `dist` and nothing else: the Vite bundle, its assets, and `index.html`. Every import is resolved at build time (the bundle contains no bare specifiers), and the only consumer is `flow-cli studio`, which reads this package's `package.json` and `dist/index.html` and serves the directory. So the manifest declares no runtime `dependencies` at all: React, React Flow, ajv and the three `@flow-as-code/*` packages are `devDependencies`, since they are inputs to the build rather than things an installer must fetch. They were runtime dependencies until A13, which would have made every `npm install @flow-as-code/cli` pull React and React Flow that nothing loaded. ## Export targets "Export as…" in the toolbar covers the four targets in docs/02-studio-design.md, over the whole document set rather than the open document alone (that is what the IaC targets mean: one terraform configuration, one binder). The open document is taken from the canvas, so unsaved edits are included; the rest are read from the store. | Target | What it writes | What you supply | | ----------------------------------- | ----------------------------------------------------------------------------- | --------------------------------- | | **Terraform** | `@flow-as-code/tf`'s file map: `flows.tf`, `flow_refs.tf`, `flows/*.tftpl`, … | a terraform address per reference | | **Terraform (flowascode provider)** | `@flow-as-code/hcl`'s `flows.tf`, `variables.tf`, `versions.tf.example` | the same address map | | **CDK** | `flow-stack.ts`, a `FlowSet` stack with a TODO `TokenBinder` | nothing | | **Raw JSON** | `.json` per document, materialized Flow language | a resolved value per reference | Parity with the CLI is the feature, not a side effect. The studio does not reimplement an emitter: the CDK scaffold is `@flow-as-code/cdk/scaffold`, the same generator behind `flow-cli emit --target cdk`; Terraform is `emitTf` from `@flow-as-code/tf/emit`; the flowascode target is `emitFlowascode` from `@flow-as-code/hcl`; raw is `@flow-as-code/core`'s `materializeWithMap` plus `serializeContent`, with `flow-cli render`'s file names. `tests/exportParity.test.ts` runs the BUILT CLI as a subprocess and compares bytes, because "they call the same function" is what a parity test must not assume, and `tests/exportTofu.test.ts` runs the studio's own Terraform output through `tofu validate` on `@flow-as-code/tf`'s harness (gated on `RUN_TOFU_VALIDATE=1`, as `@flow-as-code/tf`'s are), and runs the flowascode output through `tofu validate` against the published provider from OpenTofu 1.10, the provider's floor, with `tofu fmt -check` on every lane. The address-map editor lists every reference in the set and refuses a literal ARN inline, the same rule the ref pickers apply to parameters: a terraform address is an expression that resolves to an ARN at apply time, never the ARN itself. What is still unmapped is asked of the emitter rather than recomputed, by reading back the `# TODO: no terraform address for …` lines it writes, so a reference the set resolves itself (a module it also emits) is correctly not asked for. The resource map for raw export is the opposite case and accepts ARNs, because that output is deployable Flow language; an incomplete map refuses the export and names every missing token, not the first. Exports run the save gate. `src/export/targets.ts` calls `assertSaveable` on every document before emitting anything, so no target can write a document that fails a hard lint rule or the schema, and `tests/exportGate.test.ts` asserts that against the export functions rather than against the buttons. Delivery is one code path with two ends (`src/export/deliver.ts`). Served by `flow-cli studio`, the file map is POSTed to `/bridge/export` and the CLI writes it, because the bridge is the only thing that touches disk; without a bridge the browser downloads each file, folding the directory into the name (a browser cannot create one) while the dialog lists where each belongs. The bundle is built once, before either sink sees it, so the two cannot drift. The studio bundles `@flow-as-code/cdk`, `@flow-as-code/tf` and `@flow-as-code/hcl` for those targets, but only their browser-safe entry points: `@flow-as-code/tf/emit` is the pure emitter, while the package index also carries `writeTf`, which imports `node:fs`; `@flow-as-code/hcl` is browser-safe throughout, so its root is the one specifier the studio may import. `tests/browserSafe.test.ts` fails on a node builtin anywhere in `src/`, because the tests run in node and would not otherwise notice. ## Read-only stores and the hosted demo `DocStore.readOnly` marks a store whose `write` always refuses. `ReadOnlyStore` in `src/store/readOnlyStore.ts` wraps any store that way, and `defaultStore` boots the demo through it when `DEMO_BUILD` (`src/demoBuild.ts`, true only under `vite build --mode demo`) is set. The canvas still edits, because the document in memory is the app's own copy; the toolbar hides Save, Open file, Open folder, and the FlowDoc download, shows a "Read-only demo" badge, and labels dirty state as "edited in this tab". "Export as…" stays and its sink becomes `PreviewExportSink`: the same bundle every other sink receives, returned as text and shown in the dialog instead of downloaded. The static build itself is described in docs/05-hosted-demo.md. ## Documents another generator owns `meta.generator` has two forms of this toolchain's own (docs/01-flowdoc-spec.md): `core@` from `synth()` and `cli@` from `flow-cli synth` and the bridge. A document carrying anything else there was written by a generator the user runs, and an edit on the canvas lasts until its next run. The toolbar badges it (`generated-badge`, "Generated by "), and the first edit is held rather than applied while a dialog (`src/components/GeneratedModal.tsx`) says the next run will overwrite it: "Edit anyway" applies the held edit and stops asking for that document, "Keep it as generated" drops it. Nothing is written to the document for any of this; `src/model/generated.ts` decides which form is foreign, and `tests/generatedUi.test.tsx` covers the badge and the question. ## The bridge (served by `flow-cli studio`) `BridgeStore` in `src/store/bridgeStore.ts` is the DocStore over the local `flow-cli studio` server, and it is what makes the round trip live. The browser cannot write files, so a save is a `PUT` and the CLI writes both the FlowDoc and its regenerated companion, `.flow.ts` or `.flow.tf`. `assertSaveable` runs here before the request, exactly as it does in the other stores, and the server validates the document again on its side. The toolbar badges the open document's companion (`companion-badge`), and New flow (`src/components/NewFlowDialog.tsx`, bridge only) creates a document with the companion the user picks through `BridgeStore.create`. A `.flow.tf`'s `lint.disable` list reaches the lint worker as `LintRequest.disable`, which ignores any hard rule it names. The app knows it has a bridge because the server injects a description into the served `index.html` (`src/store/bridgeProtocol.ts`, `BRIDGE_GLOBAL`), which is read synchronously at boot. There is no probe request, so a static build makes no network call at all and still opens the built-in demo. The protocol file is a byte-for-byte copy of `packages/cli/src/bridge/protocol.ts`; a test in `@flow-as-code/cli` fails if the two drift. Live updates arrive on a long poll (`subscribe`), and a builder-file edit reaches the canvas without a reload: React Flow keeps the viewport because the canvas is not remounted, and the selection survives whenever the selected block still exists. Conflicts are a question, never a merge. `ConflictModal` shows a side-by-side diff of the two FlowDocs (`src/model/docDiff.ts`, over the canonical serialization) and the user picks a side; until then `state.conflict` is set and every write path is blocked. Three states raise it: the two files diverged on disk, a save whose builder file has moved since the document was generated from it (the bridge answers 409), and a builder-file edit arriving while the canvas holds unsaved edits, which the reducer turns into the same dialog instead of replacing the document. Develop with `npm run dev`, test with `npm test` (pure model tests plus happy-dom smoke tests), build with `npm run build`. Three files need a build first: `tests/bundle-offline.test.ts`, which fails with a message naming the command when `dist/` is missing, and `tests/bundle-boot.test.tsx` and `tests/exportParity.test.ts`, which skip silently rather than repeating it. The demo build (`npm run build:demo`, `dist-demo/`, docs/05-hosted-demo.md) has the same arrangement: `tests/demo-bundle.test.ts` fails one test naming the command, `tests/demo-boot.test.tsx` skips. --- # Conformance suite Cross-language and cross-tool contract. The future Go provider vendors this directory and must pass identically. ``` schema/flowdoc-0.3.schema.json machine-readable FlowDoc definition (current version) schema/flowdoc-0.2.schema.json the previous version, frozen; migrate inputs validate against it schema/flowdoc-0.1.schema.json the version before, frozen likewise migrate//input.flowdoc.json a document at an older version migrate//expected.flowdoc.json the exact bytes migrateFlowDoc turns it into migrate/invalid.json versions no build reads, which must be refused layout/README.md the auto-layout algorithm every implementation assigns to unplaced actions layout//doc.flowdoc.json a document whose actions the algorithm lays out layout//expected.layout.json the positions it must produce, byte-exact flow-language/actions.md Connect action types, shapes, and cited doc URLs flow-language/catalog.json the same facts as data; a second implementation generates its schema from it flow-language/probes//.json a create probe behind a numbered rule, every id a placeholder (probes/README.md) flow-language/probes//results.json what the service said to each probe, with the UTC time and Region demo/appointment-line.flowdoc.json the canonical demo flow lint/README.md fixture format and the rule for adding one lint//pass-*.json FlowDoc producing no finding for the rule lint//fail-*.json FlowDoc plus expected findings [{rule, blockId, messageIncludes}] roundtrip//doc.flowdoc.json codegen->synth must reproduce the doc (modulo meta) codegen//case.json description, the document (relative path), codegen options, an optional previous source codegen//previous.flow.ts optional; the source on disk whose banner and @keep comments survive the regeneration codegen//expected.flow.ts the generated source, byte-exact emit-tf//case.json emitter case description, inputs, and validate expectation emit-tf//.flowdoc.json optional; input documents a case does not borrow from demo/ or roundtrip/ emit-tf//address-map.json optional; reference key -> terraform address expression emit-tf//expected/ golden .tf and .tftpl output tree, byte-compared emit-tf//validate/ optional test-only providers and stub resources materialize//doc.flowdoc.json input FlowDoc for a materialization case materialize//map.json token -> resolved value (map backend cases) materialize//binder.json token -> opaque binder output (binder backend cases) materialize//expected.content.json deployable content golden, byte-compared export//inventory.json recorded instance inventory (the List* responses) export//flows/.json DescribeContactFlow Content, verbatim live Flow language export//flows/.saved.json content of a flow that has never been published export//expected/.flowdoc.json exported FlowDoc golden export//expected/.flow.ts codegen golden for that FlowDoc export//expected-error.json {unknownArns, interpolatedArns} for a case that must fail hcl/README.md the HCL contract: a FlowDoc as a flowascode resource and back hcl/address-sugar.json resource addresses the TypeScript parser rewrites to a reference key hcl/roundtrip//case.json description, the document (relative path), options, validate expectation hcl/roundtrip//bindings.json reference key -> terraform address; a key absent here is unbound hcl/roundtrip//expected.flow.tf the resource, byte-exact, a `tofu fmt` fixed point hcl/regenerate//doc.flowdoc.json a document whose companion is rewritten (TypeScript only) hcl/regenerate//previous.flow.tf the companion before the rewrite hcl/regenerate//expected.flow.tf the companion after: carried values, kept comments, normalized sugar hcl/parse//input.flow.tf a hand-written resource, fmt or not hcl/parse//expected.flowdoc.json the document it reads to hcl/parse//expected.sidecar.json what the file carried beside the document: refs, lint, tags, instance id, normalizations hcl/refuse//input.flow.tf a resource both implementations refuse hcl/refuse//expected-error.json {code, path?, messageIncludes?}; the code is the cross-language field schema/scenario-0.1.schema.json machine-readable simulate scenario definition simulate//scenario.json authored simulate scenario simulate//expected.testcase.json compiled CreateTestCase input, tokens still in place simulate/invalid/scenarios.json scenarios that must be rejected, with finding paths simulate/dry-run/flows/*.flowdoc.json the flow set the offline dry run checks scenarios against simulate/dry-run/resource-map.json the map it resolves tokens the set lacks through (keys only) simulate/dry-run/cases//scenario.json a valid scenario held against that set simulate/dry-run/cases//expected.problems.json the [{path, message}] dryRunScenario must report, [] for a clean case simulate/report/run.json a simulation run simulate/report/expected.junit.xml JUnit reporter golden, byte-compared simulate/report/expected.report.json JSON reporter golden, byte-compared ``` An export case's `flows/.json` is what `DescribeContactFlow` returned, so a literal ARN there is the point: it is the input side of export. Only the `expected/*.flowdoc.json` goldens are authored FlowDocs, and those stay ARN-free. `flows/.saved.json` stands in for a flow that has never been published: describing it without the `$SAVED` alias throws ContactFlowNotPublishedException, as the API does. A simulate `expected.testcase.json` keeps `${cdref:...}` tokens: compilation and token resolution are separate steps, so the golden never carries an ARN. The compiled `Content` shape is the one artifact here that no offline test can confirm, because CreateTestCase validates it server-side. The canonical simulate cases target `demo/appointment-line.flowdoc.json`, except `keypad-press`, written for `simulate/dry-run/flows/` (the one flow set here with a keypad block), which is the golden for a compiled DtmfInput SendInstruction; every canonical case dry-runs clean against the set it is written for. A simulate dry-run case is a scenario the schema and `validateScenario` both accept, held against `dry-run/flows/` with `dry-run/resource-map.json`: the findings must equal `expected.problems.json` exactly, path and message. The map's values are never read, only its keys, which is why the fixture shows each of the three key forms once. An emit-tf case is a whole emitter run. `case.json` carries a `description`, a `docs` array of FlowDoc paths relative to the case directory (a case may point at `../../demo/` or `../../roundtrip/` rather than copy a document), optional `options` passed to the emitter, and `validate`, which is `pass`, `fail`, or `skip`. `address-map.json`, when present, becomes `options.addressMap`. The emitted files must equal `expected/` byte for byte, so the tree also pins the file set: an added or dropped output file fails the case. `unbound` and `unusedMapKeys` say what the run reports beside its files: the reference keys the set resolves nowhere (placeholders here, `null` bindings in the hcl emit cases) and the address map keys no reference in the set uses; the TypeScript tests hold both, and a provider that emits may too. `validate/` is not emitter output. It holds the minimum provider configuration and stub resources a `terraform validate` or `tofu validate` run needs for the emitted addresses to resolve, and every file in it says so at the top. A case marked `pass` must validate clean with those files present; a case marked `fail` must fail, which is how the TODO placeholders for unmapped references are proved to be loud rather than silently deployable. Those runs need a real binary and a provider download, so they are gated behind `RUN_TOFU_VALIDATE=1` and run in CI's emit-tf job. The goldens themselves are compared on every run. A materialize case carries `map.json` or `binder.json`, never both. Binder cases apply the file as a token -> output lookup so the opaque values are recorded in the fixture. Literal ARNs are legal in `map.json` and in `expected.content.json`: producing them is the point of materialization. They remain forbidden in every authored FlowDoc, `doc.flowdoc.json` included. Every rule, builder feature, codegen case, and emitter case lands with fixtures in the same commit. Roundtrip fixtures are FlowDocs in synth normal form: declaration-ordered Actions, `Errors` and `Conditions` arrays present (possibly empty) on every non-terminal action, `{}` transitions on terminal actions, sorted keys, and a `layout` covering every action. A provider generates code from `doc.flowdoc.json`, executes it, synthesizes the result, and must get the same document back (ignoring `meta`). The cases cover the demo flow (`appointment-line`), a flow of actions the builder does not model, with tokens inside parameters (`unknown-actions`: unmodeled Types, and since tasks/D04 two forms of modeled Types that their classes do not write, `CompleteOutboundCall` with `VoiceConnector` and `StartOutboundChatContact`'s WhatsApp form), a module invoking another module by alias (`after-call-survey`), a flow where Compare is the only user of `jsonPath` (`compare-only`, so the import is emitted for it), the same Compare with a `NextAction` that is a condition's target rather than the `NoMatchingCondition` copy the class writes, which the service accepts and which must stay GenericBlock (`compare-unmirrored-next`), a flow of `GetParticipantInput` menus with Text, SSML and PromptId bodies (`dtmf-menu`), a `GetParticipantInput` whose key branches twice, which the builder refuses and so must stay GenericBlock (`repeated-key`), a module whose description runs past the print width and a value under a key wide enough for Prettier to break after it (`long-description`, which pins the two shapes that escaped the fixed-point test until 2026-10-05), edge cases (SSML, PromptId refs, Compare branches, JSONPath refs, hand-placed layout, an explicit start, and a modeled Type that must fall back to GenericBlock), a module of the contact-routing actions (`contact-routing`: queue-to-queue transfer to a queue token, to an agent queue by JSONPath, and with no parameter, a shape the page allows without saying where the contact goes, a terminal transfer to an agent, a routing priority and a queue time adjustment, a non-terminal action with no error branch, a callback number set from a JSONPath with its two named errors and no catch-all, and a callback contact in its full form, with a queue, a creation flow and a caller ID, and in its minimal one), a flow of the flow-control actions (`flow-control`: a Loop whose continue path is a back edge, a Wait with both events and its conditional ParticipantNotFound branch, a percentage split in the console's threshold form, a flow attribute set in the console's `{ Value }` form with its catch-all, a staffing check and a queue-depth check in the order of the console's default queue transfer export, and a queue metric load with a static channel and one with a dynamic channel); a flow of the contact-data actions (`contact-data`: a tag set with a dynamic value, a tag removal, a voice change with a static and a dynamic engine, a contact data update with every Voice ID field and a minimal one, and an event hook set to a flow token and one set by JSONPath); a customer queue flow of the participant actions (`participant`: a loop of prompts in every message kind with an interrupt, one that holds the contact with nothing wired, and a Lex V2 bot with intents, session attributes and a timeout, plus a bare one, and a view shown with data, a hidden transcript and a time limit, plus a bare one); and a flow of the recording block (`recording-analytics`: voice recording of both participants with IVR recording, a voice-only form, a screen-only form (the service takes one form per block), and the chat analytics form the builder leaves generic, with its third error); and a flow of the stored-input form of `GetParticipantInput` (`stored-input`: digits kept by length, a local phone number with its country code and its `InvalidPhoneNumber` branch, and an E.164 number, with Text, PromptId and SSML bodies; the menu form stays in `dtmf-menu`); and a flow of the contact-state actions (`contact-state`: the previous participants put on hold in each of the three states, the customer's audio streamed both ways and from the customer alone and the stream stopped, chat messages processed by a Lambda reference with and without delivering what fails processing and processing stopped, and a paused task resumed); and a flow of the task and AI agent actions (`tasks-and-assistants`: an AI agents assistant associated by reference and by attribute, a task created from a flow reference with attributes, a description and a delay, from a task template reference alone, from both on a schedule read from an attribute, and with its flow and delay read from attributes, then a paused task resumed); and a flow of the Customer Profiles actions (`customer-profiles`: the caller looked up by one identifier, then by two search criteria joined by AND when several match, a profile created with listed fields and a custom attribute, storing one of each, the contact associated with it by two JSONPath ids, and a custom attribute and an email address updated, the latest order and an asset by its identifier read, and a predefined and a custom calculated attribute read); and a flow of the Cases actions (`cases`: the caller's open cases searched by system fields, linking the contact and storing a domain field by `casefield` reference beside two system fields, the last updated case read by customer id when several match, the case found updated with a domain field by reference and its summary, linking the current contact, and when none is found a case opened from a template reference with a domain field by reference and three system fields, linking the current contact, then a call log case opened without linking); and a flow setting routing criteria before a transfer to queue (`routing-criteria`: the steps read from a JSONPath, then three typed steps, an OR of an AND and a requirement, an AND of two requirements, and a requirement whose value is read from an attribute, the last never expiring); and an outbound whisper flow of the outbound actions (`outbound`: the call placed with a static caller ID, or through a Chime voice connector, a form that stays generic, then the call's progress checked on all four statuses, a person answering greeted, on voicemail an SMS from a claimed number and flow by reference, and with no detection an SMS whose number and flow are read from attributes). An HCL case is a companion file and the document it stands for. `conformance/hcl/README.md` is the contract both the TypeScript writer and the Go provider implement; every `roundtrip` golden is a `tofu fmt` fixed point, checked by a gated test, and an ungated test holds each case's document, bindings and block names to the rest of `conformance/`. The demo flow itself is the synth fixture: `packages/core/src/synth.test.ts` builds it with the typed builder and compares the result against `demo/appointment-line.flowdoc.json`. ## What a second implementation must pass The Go provider vendors this directory at a pinned commit. Each family below is one of two kinds, and `tests/conformanceIndex.test.ts` holds this list to the directories that exist, so a new family cannot land without being classified here. Both implementations: - `schema`: the FlowDoc schemas; the mutation cases in `packages/core/src/conformance.test.ts` are the rejections to reproduce. - `flow-language`: `catalog.json` is the source a second implementation generates its schema and lint tables from; `actions.md` is its prose, and `probes/` holds the inputs behind its numbered rules, which the provider embeds and does not read. - `lint`: every rule's pass and fail fixtures, findings matched by rule, block and message fragment. - `materialize`: deployable content from a document and a reference map. - `layout`: the auto-layout positions, byte-exact. - `export`: the `expected/*.flowdoc.json` half (the `.flow.ts` goldens are TypeScript only). - `roundtrip`: at the FlowDoc level, every document in synth normal form. - `hcl`: `roundtrip`, `parse` and `refuse` (its `regenerate` cases and `address-sugar.json` are TypeScript only). - `demo`: the canonical document, which the other families borrow. TypeScript only: - `emit-tf`: the `@flow-as-code/tf` emitter's goldens. - `codegen`: the TypeScript companion's text for options a round trip does not reach (the banner line, what a previous source carries). - `simulate`: scenario compilation and reporting. - `migrate`: 0.1 documents and the bytes `migrateFlowDoc` turns them into. The round-trip rule for HCL, stated in full in `hcl/README.md` rule 26: for every `hcl/roundtrip` case, reading the golden produces the case's document (modulo `meta`) and its bindings, writing the document with those bindings produces the golden byte for byte, and the golden is a `terraform fmt` fixed point. Whether an action was written as a typed sub-block or as `generic` is not part of the invariant. Fixtures are data, not source: they are excluded from ESLint and Prettier so that goldens stay byte-stable. The assertions that guard this directory live in `packages/core/src/conformance.test.ts`. --- # Contributing ## Getting started ``` npm ci npm run build # tsc -b honours the project references, then vite for the studio npm run lint npm run typecheck npm test ``` Node 22.12 or newer. CI runs Node 22 (the `engines.node` floor), 24 (the Active LTS), and 26 (the current release); local development on a newer runtime is fine, but do not add a dependency whose `engines.node` excludes 22. Node 20 reached end of life on 2026-04-30 and is no longer supported. Run `build` before `test`. Some tests read build output, and the workspace packages resolve to `dist`. `build` runs `npm rebuild --workspaces --ignore-scripts` after `tsc -b`, which is what links `node_modules/.bin/flow-cli`. npm links workspace bins during install, and at `npm ci` time `packages/cli/dist/bin.js` does not exist yet, so the link is skipped; without the relink, `npx flow-cli` from the repo root would miss the workspace and reach for a `flow-cli` on the registry. After `build`, the `npx flow-cli` lines in the root README work from a checkout. `build` then builds the studio and its demo bundle and ends with `scripts/chmod-bins.mjs`, which owns the mode of every file a workspace packs out of `dist`. The packages are on npm (all six from 0.2.0; `@flow-as-code/hcl@0.0.0` is a deprecated placeholder that was published by hand so trusted publishing could be configured for it), but inside this repository `@flow-as-code/*` resolves through the workspace and nowhere else, so a checkout never reads the registry copies. Build first or the imports do not exist. For the version the registry currently serves, read the package pages rather than this file: . ## What CI runs `.github/workflows/ci.yml`, on push and pull request: - `build / node 22`, `24` and `26`: `npm ci`, then lint, typecheck, build, `build:site`, test. `lint` includes the header check. - `emit-tf goldens / tofu 1.7.0`, `1.10.10` and `1.12.6`: the emitted HCL has to be `tofu validate`-clean against a real OpenTofu, on the emitter's floor, the flowascode provider's floor and the current release. The provider-shaped output (the `conformance/hcl` cases, the promotion example's flowascode trees, the provider example, the studio's flowascode export) is validated against the published `flow-as-code/flowascode` from 1.10.10; the 1.7.0 lane skips it. It asks for exact provider versions, pinned in `conformance/{emit-tf,hcl/*}/*/validate/providers.tf`, so a blocking lane never turns red because a third party published that morning. The canary below is what watches the newest ones instead. - `publish dry-run`: `npm publish --dry-run` once per package, which is what catches a packaging defect before a release does. That is the whole of `ci.yml`, and it is deliberately the whole of it. A release reuses this file as its gate set, and a called workflow may only downgrade the permissions its caller granted, so a job here that asks for more than `contents: read` is rejected before any job starts. `tests/releaseGates.test.ts` fails a pull request that would introduce one. The same test holds the other rule about these files: every `uses:` that leaves this repository names a full commit SHA with its version in a trailing comment, never a floating tag. `.github/dependabot.yml` is what moves those SHAs; a reference into `./.github/` needs no pin. `.github/workflows/release.yml` says why. `.github/workflows/provider-drift.yml`, weekly on Monday and on dispatch, runs the same three test projects the `emit-tf` job runs, on OpenTofu 1.12.6, with no provider cache, so it resolves whatever the registry serves that morning. It is the other half of those pins: they buy a lane that does not depend on a third party's release day, and this buys back the noticing. What it floats each file to is the range that file's own user-facing artifact declares, because "what a user gets" is not one constraint. A `conformance/emit-tf/*/validate/providers.tf` is a test-only file; its counterpart is the `versions.tf.example` the emitter writes, so it resolves under that, which is open-ended and therefore crosses a major the day one ships. An `examples/*/terraform/*/providers.tf` is itself the published advice, so it resolves under its own `~> 6.0` and keeps that range on disk. It gates nothing. It is not called by `ci.yml` or `release.yml`, it is not a required check, and it has no `workflow_call` trigger, all of which `tests/releaseGates.test.ts` holds. When it goes red, no merge and no release is blocked, and the answer is a considered pin bump rather than a scramble: read the resolved version and the first error from the run's step summary, then move `conformance/emit-tf/*/validate/providers.tf` and the matching `emit-tf` cache key in `ci.yml` together, in one commit. A test holds those two in step. The seam it runs through is `TOFU_PROVIDER_MODE=float`, read by `packages/tf/src/__fixtures__/tofu.ts`. It rewrites the constraint on the text's way to a temp directory and never touches the files in the working tree, so the guards that assert those files are pinned exactly are as strict during a canary run as during any other. Every committed `providers.tf` a gated test can init is named by `providerSites()` in the same file. `prewarmTofuCache()` downloads exactly that set before any worker starts, and `tofu()` refuses an `init` whose workspace asks for anything else. A new gated test that builds a workspace some other way therefore fails at its first init with a message naming what it asked for, rather than downloading a provider in the middle of the parallel suite. That is not hypothetical: it is how the example environments went on floating for a day after everything else was pinned. To reproduce a canary run locally: ```sh RUN_TOFU_VALIDATE=1 TOFU_PROVIDER_MODE=float npx vitest run \ --project @flow-as-code/tf --project @flow-as-code/studio --project repo ``` Either mode prints which one it used and which versions it resolved before the first test runs. `.github/workflows/integration.yml`, on push to main and on dispatch, is the live Amazon Connect sandbox run, which is where `id-token: write` is needed for the AWS role. It is skipped unless the repository variable `CONNECT_SANDBOX_ENABLED` is `true`, so a fork pull request never reaches for credentials it was not going to get, and it is a separate workflow so that a release never depends on a reachable AWS account. `.github/workflows/pages.yml` deploys the landing page and the read-only studio demo to https://flow-as-code.dev/ on push to main. `.github/workflows/release.yml` publishes to npm. It is dispatched by hand, and it calls `ci.yml` rather than restating its jobs, so the gate set a release passes is the gate set above and there is only one definition of green. See "Releasing" below. ## Releasing A release is two steps, on purpose. A publish cannot be taken back: npm allows unpublishing a version only within 72 hours, and a version number can never be reused afterwards (https://docs.npmjs.com/policies/unpublish). So the version bump is a reviewable commit and the publish is a separate, deliberate act. **Step 1, the operator, locally.** On `main`, with the changesets for the release already merged: ``` npx changeset version # consumes .changeset/*.md, bumps all six, writes CHANGELOGs npm install # refresh package-lock.json for the new versions git commit -am "release: " git push origin main ``` Review the bumped manifests and the generated CHANGELOGs before committing. All six packages are `fixed` in `.changeset/config.json`, so they move together: every package takes the new version whether or not a changeset named it. That is deliberate, and it is why every published version so far exists for every package published at the time. `fixed` is not `linked`, which the config carried earlier: `linked` gives the packages a common version but bumps only the ones a changeset actually names, so a changeset touching the CLI alone leaves the others behind. If `changeset version` ever bumps fewer than six manifests, that is the setting to check before committing. **Step 2, the operator, on GitHub.** Actions, "Release", "Run workflow", branch `main`. Nothing else. There is no npm token to supply and none exists: the publish authenticates over OIDC. **What the workflow does.** 1. `preflight`, in about a minute. Refuses to go on unless the ref is `main`, `id-token: write` is actually in effect, the file is still `.github/workflows/release.yml`, `.changeset/` holds no unconsumed changeset, at least one package version is absent from the registry, and no git tag already exists for a version that is not published. Each of those is a way for a release to half-happen or to silently do nothing. 2. `gates`, calling `.github/workflows/ci.yml`. The whole of it, granted `contents: read` and nothing more. The live sandbox job is not in that file, so an unreachable AWS account cannot block a publish. 3. `publish`. Upgrades npm if the runner's is below 11.5.1, the floor npm's trusted publishing documents, sets a committer identity so the annotated tags `changeset publish` creates can be made at all, then runs `npx changeset publish` and pushes the `@` tags. No `--access` or `--provenance` flag: `publishConfig.access` already says public, and provenance is automatic under trusted publishing. Before it exits it re-checks the registry against the remote's tags, because a version that published without a tag is the one bad end state `changeset publish` does not report on its own. **The filename is fixed.** Each published package has a Trusted Publisher on npmjs.com naming this repository and `release.yml`. npm matches that string exactly and a configuration cannot be edited, only deleted and recreated (https://docs.npmjs.com/trusted-publishers/). Renaming the file breaks every publish. **If a publish half-fails.** `changeset publish` publishes each package separately, so some can land and others fail. It publishes only versions the registry does not already have, and the workflow pushes the tags for whatever did publish before failing. So the recovery is to fix the cause and dispatch the workflow again on the same commit: the packages already on the registry are skipped, the rest are published. Do not bump versions to work around a failure, and do not unpublish. If the job failed after publishing but before pushing tags (the log says so), push the tags by hand from a checkout of that commit. ## The rules that are not negotiable These come from CLAUDE.md and they are enforced by tests, not by review alone. - **FlowDoc is the only interchange format.** Everything reads and writes it. - **References are tokens**, never literal ARNs. Lint fails on `arn:aws:` in authored content, and that rule blocks a studio save. - **Never drop content.** An action the builder does not model round-trips verbatim as a `GenericBlock`, through synth, codegen, the studio, and both emitters. - **Deterministic output.** The same input produces byte-identical output. If you add a code path that emits anything, add a test that runs it twice and compares bytes. - **Round-trip invariants hold.** `synth(codegen(doc))` equals `doc`, and the studio may not turn a modeled block into a generic one. ## Tests must be able to fail This project has repeatedly shipped guards that looked enforced and were not: a save gate that could be deleted with every test still passing, an export path that bypassed it, a live test that accepted every possible outcome, and an oracle whose error handler swallowed its own alarms. So when you add a guarantee, prove the test for it can fail. Delete or neuter the mechanism, watch the test go red, restore it, watch it go green. Say so in the pull request. A test that passes when the feature is gone is worse than no test, because it reports safety that is not there. ## Conformance fixtures `conformance/` is the cross-language contract; a future Go provider vendors it and must pass identically. Every lint rule, builder feature, codegen case, and emitter case lands with fixtures **in the same commit** as the code. Fixtures are data. They are excluded from ESLint and Prettier so goldens stay byte-stable. Regenerate emitter goldens with `UPDATE_GOLDENS=1`. ## Docs and citations Update docs in the same commit as the change. No em-dashes. Cite an AWS documentation URL in a comment for any AWS behavior you rely on. Several times this project has found the documentation contradicting an assumption that looked obviously true, so the citation is the point rather than a formality. ## Commits Small conventional commits. Explain why in the body, not just what. ## Licensing Apache-2.0. `npm run headers:fix` applies the header; `npm run lint` fails without it. The copyright holder is deliberately entity-neutral and lives in a single constant in `scripts/license-headers.mjs`. The header goes on files in this repository, never on files the tools generate into someone else's project. Codegen output, the CDK scaffold, and the Terraform emitter's files carry a "Generated by" banner and no copyright line. --- # ADR-0001: Defer jsii Status: accepted, 2026-08-30 ## Context `@flow-as-code/cdk` could be published to PyPI, NuGet, and Maven through jsii, matching how AWS ships construct libraries. ## Decision No jsii until the builder API stabilizes. ## Consequences TypeScript only for now. jsii constrains the API surface hard: no union types, no structural typing, no generics in exported signatures. The builder leans on all three, most visibly in the block config unions that make an unwired error branch a compile-time error (see [ADR-0002](0002-error-branch-enforcement.md)). Adopting jsii early would mean giving that up before we know whether the API is right. Revisit once the block set stops changing. --- # ADR-0002: Error branches are required config properties Status: accepted, 2026-08-31 ## Context Task A01 requires that "an unwired error branch is a type error". Two obvious implementations exist, and they interact badly with a different non-negotiable. A fluent builder (`flow.play(...).then(...).onError(...)`) can enforce wiring at the type level only through phantom types or builder-state threading, where each call returns a different type carrying what has been wired so far. That collides with the codegen requirement: "Codegen output must be idiomatic, diffable TypeScript that a human would plausibly have written." State-threaded fluent chains are not what a human writes, and they are painful to emit and to diff. ## Decision Blocks are classes taking a single config object. Error branches are **required properties** on that object. ```ts new TransferContactToQueue({ id: "transfer", next: "hang-up", onQueueAtCapacity: "announce-busy", onError: "apologize", }); ``` Mutually exclusive parameters use discriminated unions, so supplying both `text` and `ssml`, or both `queue` and `agent`, is also a compile-time error. ## Consequences Omitting a required property is a genuine `tsc` error, verified in both directions in `packages/core/src/synth.test.ts`: removing a `@ts-expect-error` produces TS2345, and an unnecessary one produces TS2578, so the assertions cannot rot into no-ops. Codegen emits plain object literals, which diff cleanly line by line. The escape hatch is untyped by necessity. `GenericBlock` models actions we do not know, so we cannot know their error sets either. The `error-branches` lint rule is the backstop there. Blocks map one-to-one onto Actions for the same reason. A block that expanded into several Actions could not be reliably recognized on the way back, which would break the round-trip invariant. "Transfer to queue" is therefore two blocks, `UpdateContactTargetQueue` and `TransferContactToQueue`, because that is what Connect actually models. --- # ADR-0003: Synth preserves declaration order Status: accepted, 2026-08-31 ## Context FlowDoc invariant 1 requires stable action ordering. Two candidates: a canonical order derived from the graph (breadth-first from `StartAction`), or the order the author declared blocks in. ## Decision Synth emits declaration order. ## Consequences Reachability order interleaves error handlers into the happy path. On the demo flow it produces `welcome, check-hours, apologize, announce-closed, set-working-queue, ...`, because `apologize` is an error target of `welcome`. That is not how a person writes a flow, so codegen would emit code that reads wrong, against the codegen non-negotiable. Declaration order also makes `codegen(synth(code))` byte-stable with no normalization pass rather than one, since nothing is reordered in either direction. Determinism still holds: declaration order is part of the input, so the same input yields byte-identical output. Reachability order is still useful for lint's `reachable-blocks` rule and for comparing two flows that are semantically identical but declared differently. It stays available as the exported `canonicalOrder`, just not applied by synth. --- # ADR-0004: Prior art, and why this is not Amazon's L2 CDK library Status: accepted, 2026-09-10 ## Context On 2026-07-21 the AWS Contact Center blog described an internal L2 CDK construct library for Amazon Connect flows: https://aws.amazon.com/blogs/contact-center/managing-amazon-connect-flows-as-code-with-aws-cdk/ It has a typed TypeScript builder, bidirectional TypeScript to Flow JSON conversion, a centralized ARN mapping system for environment-specific configuration, and a pluggable rule engine that validates flow structure and dependencies at build time. That overlaps a large part of what this repository does, and anyone evaluating this project will reasonably ask why it exists. The post says Amazon is "exploring making the L2 construct library available as an open-source repository". As of 2026-09-10 no repository or package name has been published, so this is a comparison against a described design, not against running code. ## Decision Build it, and be explicit about the two differences that are real. **Reference resolution.** The described library resolves references through a centralized mapping table: the build reads the table and writes concrete ARNs into the flow. Here, a reference is a `${cdref:type:name}` token that stays a token through FlowDoc, codegen, synth and both emitters, and is expanded by the IaC engine at deploy time, per environment. Lint fails on a literal `arn:aws:` in authored content. See [ADR-0002](0002-error-branch-enforcement.md) for the adjacent case of pushing a class of error to build time rather than deploy time, and `docs/01-flowdoc-spec.md` for the token grammar. The practical consequence is what `examples/promote-across-environments/` demonstrates: the CDK path takes no map at all, and the Terraform path emits trees for two environments that differ in exactly one file, `flow_refs.tf`. `--address-map` is still a table, but its values are Terraform addresses of resources the reader's own IaC already manages, not per-environment ARNs to maintain by hand. **Scope.** Three things this set has that the described library does not: a visual editor over the same document the code produces, Terraform and OpenTofu output alongside CDK, and a simulate-based test harness. ## Consequences The comparison is against a design, not an implementation, and it goes stale the moment Amazon publishes. Anything this repository claims about the difference has to stay inside what the code proves, which today means the two paths in `examples/promote-across-environments/` and the invariants in `tests/promoteAcrossEnvironments.test.ts`. If the library ships and resolves references the same way this one does, the first bullet stops being a difference and this ADR should be superseded rather than quietly edited. ## Rechecked 2026-09-30 The trigger in Consequences has not fired. The post is unchanged: it still says the library may be made available as an open-source repository, and still describes a centralized mapping of environment-specific ARNs. No repository, npm package or Construct Hub entry for it has been published. So both differences above still hold, and this record stands. What has changed is on this side, and has its own records: the Terraform path is no longer only the emitter. A provider manages flows written as HCL action blocks ([ADR-0006](0006-provider-shape.md)), and HCL is a third view over the same document beside TypeScript and the canvas ([ADR-0007](0007-hcl-third-view.md)). The Scope paragraph understates that; it is not wrong. --- # ADR-0005: An owned auto-layout replaces dagre Status: accepted, 2026-09-11 ## Context Synth, export, materialization and the studio all fill in a position for any action that has none, and codegen omits a document's layout when it equals that auto-layout, so the generated TypeScript carries only hand placement. Phase B gives a document a second authored companion, `.flow.tf`, and the same rule applies per action: a `position` block is written only where the action sits somewhere other than where auto-layout would put it. The Terraform provider reads those files without this package, so it has to compute the same positions or it cannot tell an omitted position from a moved one. Until now the positions came from dagre, a JavaScript layout library. Its output depends on float arithmetic, tie-breaking inside a network-simplex ranking and an ordering heuristic that the library documents only as code. Three ways were considered: port dagre to Go and prove the two agree to the integer (thousands of lines, and a proof that would have to be repeated on every dagre release), let the provider write no `content.Metadata` when a position is missing (every hand-written `.flow.tf` would then deploy with no console layout, which is the provider's main use), or replace dagre with an algorithm small enough to specify and pin with fixtures. ## Decision The third. `packages/core/src/layout.ts` implements the layered algorithm written down in `conformance/layout/README.md`: depth-first discovery from `StartAction` with back edges dropped, longest-path ranks, discovery order within a rank, unreachable actions past the deepest rank in document order, integer coordinates from four constants. `conformance/layout//` pins it, and every implementation runs those cases. ## Consequences Every fixture and golden that carried dagre coordinates was regenerated once, and the hosted demo's screenshot was retaken. Flows that were auto-arranged look plainer than dagre made them, because the algorithm does not minimise edge crossings; hand placement is untouched, and the studio still persists it. `@flow-as-code/core` now has no runtime dependency at all. Document order is part of the input: the same graph declared in a different order may lay out differently. That is deliberate, and it is what ADR-0003 already says about order everywhere else. --- # ADR-0006: A Terraform provider of managed resources that take action blocks Status: accepted, 2026-09-11 ## Context Phase A deploys flows through Terraform one way: `@flow-as-code/tf` turns a FlowDoc set into `aws_connect_contact_flow` resources whose `content` is a `templatefile` over a rendered `.flow.tftpl`, with the reference ARNs bound in `flow_refs.tf`. A flow is authored in TypeScript or on the studio canvas, and Terraform sees only the rendered JSON. Four documents recorded the reverse direction, HCL to FlowDoc, as deferred to a "provider era". The aim of Phase B is to author and manage flows natively in Terraform: the actions written as HCL, validated at plan time, and applied through the Connect API with no rendering step in between. Two shapes were considered. A data source over the existing `hashicorp/aws` resource would turn HCL action blocks into the `content` string and leave the create, update and delete calls to `aws_connect_contact_flow`. A set of managed resources would own the whole lifecycle and call the Connect API directly. ## Decision Managed resources: `flowascode_contact_flow`, `flowascode_contact_flow_module`, `flowascode_contact_flow_module_version`, `flowascode_contact_flow_module_alias`, and a read-only `flowascode_view` data source, in a separate repository, `flow-as-code/terraform-provider-flowascode`, written in Go on the plugin framework and published as `flow-as-code/flowascode` on the Terraform Registry and the OpenTofu registry from its first tag. A resource takes one repeated `action` block: `id`, `next`, exactly one typed sub-block named after the action type in snake_case (or `generic {}` for a type the catalog does not model), then `condition` and `error` blocks, then an optional `position`. Reference fields hold the key form of a reference (`"queue:front-desk"`), and one `refs` map on the resource binds each key to a Terraform expression; a literal ARN in a field or a `refs` value is a plan-time error. Blocks were chosen over nested attributes against the plugin framework's own advice ("Use nested attribute types instead of block types for new schema implementations", https://developer.hashicorp.com/terraform/plugin/framework/handling-data/blocks): a flow is read top to bottom as a sequence of steps, which repeated blocks show and a list attribute of objects hides behind brackets and commas. The provider's schema is generated from `conformance/flow-language/catalog.json`, the file that also holds `@flow-as-code/core`'s tables, so the two languages cannot disagree about a parameter's name or kind. The provider vendors `conformance/` at a pinned commit and passes the families `conformance/README.md` names; its plan-time `flowdoc` attribute, the canonical FlowDoc of the configuration with reference tokens in place, is what the cross-language cases compare. Lint runs at plan time with the eleven rules ported to Go; hard rules are errors and cannot be disabled, and the rest are warnings that `lint { disable = [...] }` may silence. The emitter keeps its `--target tf`. The provider is a second target, `flowascode`, not a replacement, and docs/06-terraform-provider.md says when to pick which. ## Consequences Two implementations of one contract are held together by fixtures rather than shared code, which is what `conformance/hcl/` is for (ADR-0007). The provider uses the Connect API's own module version and alias operations, so it needs no `awscc` resource; the emitter still writes those for the `tf` target. Migration from `hashicorp/aws` is by `import` and `moved` blocks (the state move needs Terraform 1.8 or OpenTofu 1.10 and later), so the provider's floor is higher than the emitter's OpenTofu 1.7.0. Live acceptance tests gate every provider pull request, a choice taken against the recommendation: an unreachable sandbox account blocks every merge, and a pull request from a fork cannot pass the gate. --- # ADR-0007: HCL is a third view over FlowDoc, paired by file Status: accepted, 2026-09-11 ## Context Phase A keeps one interchange format, FlowDoc, with two authored views over it: the TypeScript builder (`.flow.ts`, regenerated by codegen and re-synthesized on edit) and the studio canvas. A document has one companion source file, paired by suffix, and the bridge keeps the two in step within a second. With a Terraform provider that takes action blocks (ADR-0006), HCL becomes a third way to write the same document, and the question is whether it is a rendering target, like the emitter's output, or a view with the same standing as the `.flow.ts` file. ## Decision A view. A document may have a `.flow.tf` companion instead of a `.flow.ts` one, chosen when the document is created and switched only by an explicit `flow-cli convert`. The studio reads and writes the `.flow.tf` the way it reads and writes the `.flow.ts`: a canvas save regenerates it, an edit to it re-syncs the canvas, and the same dirty guard and conflict dialog apply. `meta.sourceKind` records which companion a document has. The mapping between HCL and FlowDoc is a contract in this repository, `conformance/hcl/README.md`, with byte-exact goldens: writing a document produces exactly one resource text, reading that text produces the document again modulo `meta`, and the text is a `terraform fmt` fixed point. A pure TypeScript package, `@flow-as-code/hcl`, implements the reader and writer for the browser and the CLI with no wasm; the Go provider implements the reader over Terraform's evaluated configuration. Neither parses the other's output: the fixtures do. What a `.flow.tf` carries that a FlowDoc does not (the `refs` bindings, `instance_id`, `tags`, `state`, `lint.disable` and the meta-arguments) is carried across regeneration from the previous file, never rewritten from the document; comments survive only under the `@keep` marker rule codegen already uses. A reference written as a resource address (`aws_connect_queue.x.arn`) in a reference field is sugar the TypeScript side normalizes to the key plus a `refs` entry on the next save; the provider refuses it and says what to write. ## Consequences Every document has exactly one companion, so a directory holding both a `.flow.ts` and a `.flow.tf` for one name is an error the watcher reports rather than a choice it makes. `flow-cli convert` between the two drops what the other side cannot hold: converting to TypeScript loses the bindings, the instance id, the tags and the lint settings; converting to HCL starts with null bindings and `var.connect_instance_id`. What does not round-trip is recorded rather than smoothed over. Whether an action is written as a typed sub-block or as `generic` is not part of the invariant, since both read to the same action, and the catalog's shape, not the builder, decides which (a form the builder leaves generic can still be typed in HCL). A `.flow.tf` written by hand in a shape the writer would not produce is normalized on its first regeneration. ## Round-trip limits Recorded as building the reader, the writer and the provider found them. Each is held by a fixture or a test named here, so none is a promise made in prose only. - Typed or generic is the writer's choice, not the author's. An `integer` parameter holding a JSONPath makes the whole action `generic`, because the provider's attribute for that kind is a number (contract rule 11), and an `integerString` a JavaScript number cannot hold exactly (`-0`, anything past 2^53) stays a string. `packages/hcl/src/document.test.ts` holds both, and the `numbers` round-trip case holds the ordinary forms. - Positions come back as integers. A fractional position (the console exports them) is rounded on write, and a `position` block equal to the auto-layout's is not written at all, so a document laid out by the owned layout carries no positions in HCL (rule 15; `positions`, `missing-positions`). - Comments survive only under `@keep`, directly above the resource or an action block; every other comment is gone after the next regeneration (`regenerate/keep-comments`, `parse/comments-everywhere`). - Address sugar is the TypeScript reader's alone. `aws_connect_queue.x.arn` in a reference field is rewritten to a key plus a `refs` entry on the next save; the provider refuses the same text as `REF_EXPRESSION_REFUSED`, since it sees only the evaluated value (`refuse-alias-sugar`, `sugar-*`). - Some refusals belong to Terraform. A lone surrogate escape and a repeated attribute are refused by HCL's own parser before the provider sees anything, and an unknown attribute or block by Terraform's schema check; the TypeScript reader refuses the same inputs with the contract's codes (`terraformError` in those cases' `case.json`). - A literal ARN in `refs` is refused only by the TypeScript reader (rule 20). Terraform validates a resource twice per plan, the second time with references to existing resources already resolved to ARNs, so the provider cannot tell a literal from a resolved address (`refuse-literal-arn-in-refs`, `typescriptOnly`). - Layout is not drift. `content_hash` hashes the content without Metadata, so moving a block on the canvas changes `content` but not the hash a module version is keyed to, and the provider's drift check ignores Metadata too (the provider's `TestLayoutIsNotDrift`). - Import names keys after the live resources. A flow read from an instance with no `refs` in state binds each ARN through the instance's inventory, and each key is derived from that resource's name, so a key chosen differently when the flow was authored comes back under the name-derived one. Drift on a managed flow keeps the keys in state. The provider's `TestImportRecoversBlocksAndBindings` and `TestDriftShowsAsBlocksAndIsCorrected` hold the two. --- # ADR-0008: Cases field ids are `casefield` references, usable as map keys Status: accepted, 2026-10-05 (owner decision 5's default, tasks/README.md, "Phase D", "Owner decisions"; implemented by D06 on the format D01 ships, except that `materialize`'s key resolution and lint's key walk landed with D01 on 2026-10-06, on review: without them a key token reached the CDK deploy path unresolved and unreported, tasks/D01, "Review") ## Context The three Cases actions (`CreateCase`, `GetCase`, `UpdateCase`) name a case's fields by id. `CaseRequestFields` is a map whose keys are field ids and whose values are the field values, static or JSONPath, and `GetCase` also takes `CaseResponseFields`, a list of field ids to read back (https://docs.aws.amazon.com/connect/latest/devguide/createcase.html and its siblings; https://docs.aws.amazon.com/connect/latest/adminguide/cases-block.html). A field id is a UUID that belongs to one Cases domain, and an instance has one domain, so the dev, qa and prod instances of a deployment each carry their own id for what an author thinks of as the same field. A flow written against the dev domain does not deploy against the prod one unless the ids are bound per environment. The showcase's invariant is that its environments differ only in bindings, and that invariant is what FlowDoc tokens exist to keep. Every token mechanism in the repository is written for values. Invariant 4 of `docs/01-flowdoc-spec.md` says a token "occupies an entire field value"; the catalog's path grammar (`packages/core/src/paths.ts`) names a top-level key, a key inside an object, a key inside every list element, and `A.*`, every value of a map; `materialize`'s `resolveDeep` swaps whole string values and copies keys through; lint's `walkStrings` visits values; the schema's `refField` is applied to parameter values; the HCL contract's rule 11 writes a `map` as an object literal with its keys in byte order and says nothing about what a key may be; and the provider's `flowmodel.attribute()` makes a `map` a `MapAttribute` of strings, whose keys Terraform does not type. Only the refs index (`collectRefs` in `refs.ts`) is already indifferent: it scans the serialized content for tokens, so a token in a key would be indexed today. Three shapes were weighed. - A `casefield` reference type, with tokens standing as map keys and as list items, and a path form that names the keys of a map. - Literal ids, recorded as an accepted limitation: the flow carries the UUID, and a multi-environment user keeps one document per environment or rewrites the keys outside the tooling. - A FlowDoc-level alias table: the content carries a readable alias as the key, a per-document table maps alias to id, and materialization substitutes. ## Decision The reference type. `casefield` joins the token grammar in FlowDoc 0.3 (`${cdref:casefield:priority}`), with the slug rules every type has; the `@` suffix has no meaning for it and `Refs.casefield(name)` never writes one. A `casefield` token may stand as a whole map key in `CaseRequestFields` and as a whole item of `CaseResponseFields`, and invariant 4 is restated to cover a key: a token occupies an entire key or value and is never interpolated. The catalog names key positions with a new path form, `A.*~`, every key of the map at `A`, so `CreateCase`'s refs are `CaseRequestFields.*~` and `GetCase`'s add `CaseResponseFields[]`, which the grammar already reads as every item of a list. `readPath` returns a hit per key whose value is the key string, so `REFERENCE_FIELDS`, export, lint and the studio read keys through the one table they read values through. The trailing `~` is the property-name operator of JSONPath Plus, borrowed rather than invented. What each reader does with it, implemented in D06 on D01's format: - `materialize` resolves a key that parses as a token exactly as it resolves a value, anywhere in the content, and the provider's port does the same. Resolution stays token-driven, not path-driven, so a key and a value are handled by one rule. (The TypeScript side landed in D01; the provider's port is in D01's provider batch.) - Export's inventory gains the Cases `ListFields` call for the domain the instance's `CASES_DOMAIN` integration association names, and maps each id back to a token by the field's name. A field id is not a Connect ARN, so `parseConnectArn` is not the way in; the catalog path is. - Lint's `walkStrings` visits keys as well as values (landed in D01), so `no-unresolved-token` reports a malformed token in a key and `no-literal-arn` sees every string. A literal UUID in a key is not refused by anything: the service accepts it, it is not an ARN, and it round-trips as authored. D06 adds a warning only if its sweep shows the service validating ids at create. - The 0.3 schema's `CreateCase`, `GetCase` and `UpdateCase` clauses constrain `CaseRequestFields` with `propertyNames`, a token of the type or a plain id, and `CaseResponseFields` items the same way. - HCL writes the key as the reference key, `"casefield:priority" = "high"`, and `conformance/hcl/README.md` gains the rule for a reference in a map key; `refs` binds it as any key is bound (`awscc_cases_field.priority.field_id`). The provider's map attribute is unchanged; its reader checks a key at a key-ref path against `REF_KEY` and binds it through `refs`, and its serializer writes it back. - The studio's inspector offers the field picker for keys, the same `RefPicker` it offers for values. Literal ids were not taken because the limitation lands on exactly the users the tooling is for: anyone with more than one instance, which is every promote-across-environments setup and the showcase. The ids are not ARNs, so `no-literal-arn` would not even name the problem. The alias table was not taken because a per-environment table is a binding, and bindings already have a home (the reference map, the CDK binder, the HCL `refs` map); a second substitution grammar would need its own picker, its own export, its own provider code and its own lint, to do what tokens do. ## Consequences `casefield` is one of the five reference types FlowDoc 0.3 adds in one bump (D01), and renaming or removing it afterwards would be a second format change, which the plan rules out; this ADR is merged before that bump so the set it freezes is final. The `*~` form is catalog vocabulary: `isCatalogPath` accepts it, `catalog.test.ts` gains a mutation, and the provider's `flowmodel` learns it before the re-vendor. A document that references a case field cannot be deployed to an instance without a Cases domain, as a document that references a queue cannot be deployed to one without that queue; the error is the same unbound-reference error. Export of a Cases flow needs the Cases `ListFields` permission beside the Connect `List*` calls, and the provider's documented IAM policy says so. The first HCL golden carrying a `casefield:` key lands in D01 as the first `"validate": "awaits-provider"` case (tasks/README.md, "HCL goldens before the provider release"), because the published provider's `refs` key pattern does not know the type until D10's release. --- # ADR-0009: `UpdateRoutingCriteria`'s expression is a `json` value inside typed steps Status: accepted, 2026-10-05 (the default D07 names; implemented by D07 on the format D01 ships) ## Context `UpdateRoutingCriteria` takes `RoutingCriteria.Steps`, a list of steps or a JSONPath to one, and each step holds an `Expiry` (`DurationInSeconds`) and an `Expression`. The expression is a tagged union: an `AttributeCondition` (`Name`, `Value`, `ProficiencyLevel`, `ComparisonOperator`) or an `AndExpression` holding a list of expressions, each of which may again be either (the console also writes `OrExpression`). `ProficiencyLevel` is a number with the five values 1.0 to 5.0, the only non-integer number in any documented action (https://docs.aws.amazon.com/connect/latest/devguide/flow-control-actions-updateroutingcriteria.html, fetched 2026-10-04). Two things in the toolchain cannot hold that shape as it stands. The catalog has no number kind other than `integer` and `integerString`, so a typed `ProficiencyLevel` needs a `number` kind that every reader learns. And the provider builds its resource schema from the catalog at runtime (`flowmodel.attribute()`: an `object` is a `SingleNestedAttribute`, a `list` of objects a `ListNestedAttribute`), and a Terraform schema is a finite tree: a nested attribute cannot name itself, so a recursive union has no schema form. The catalog's `json` kind exists for exactly this gap: an object the contract makes no claim about, written as `jsonencode({...})` in HCL (rule 11) and as a string attribute in the provider. Four modeled parameters use it today (`ViewData`, the two analytics behaviors, the older recording action's `AnalyticsBehavior`). Four shapes were weighed. - `Expression` as a `json` value, with the step and its `Expiry` typed and the `Steps` list marked `dynamic` for the JSONPath form. - All of `RoutingCriteria` as one `json` value. - A depth-limited typed shape: `AndExpression` and `OrExpression` unrolled as nested objects to some depth, deeper expressions falling to generic. - A `number` kind added to the catalog, so that a typed shape could carry `ProficiencyLevel`. ## Decision The first. In the catalog `RoutingCriteria` is an `object` whose `Steps` is a `list` of objects, `dynamic` (a JSONPath may stand for the whole list), and each step has `Expression`, kind `json`, required, and `Expiry`, an object with `DurationInSeconds` (its integer kind, `integer` or `integerString`, follows what a console export shows, as every integer field's did). No float kind is needed, because the only float sits inside the `json` value. What each view does with it, implemented in D07: - The builder offers a typed helper for the expression, since TypeScript expresses the recursive union directly: `attributeCondition(...)`, `and(...)` and `or(...)` producing `RoutingExpression`, with `ProficiencyLevel` as the five-valued literal union. The helper writes the JSON the console writes, codegen inverts only what the helper would write byte for byte, and any other shape stays a GenericBlock, the rule every inverter follows. - The 0.3 schema's clause validates the expression recursively through `$defs`, which JSON Schema allows where a Terraform schema does not. The `Name` and `Value` bounds and the five values enter the clause only on sweep evidence that the service enforces them at create (owner decision 3); otherwise they are the builder's checks and actions.md says so. - HCL writes `expression = jsonencode({ ... })` with Flow language key spellings in byte order (rules 11 and 12); the step list and `expiry` are typed attributes around it. The `Steps` JSONPath form has no place in a list attribute, so an action using it is written as a `generic` block, as an `integer` holding a JSONPath is today (rule 11). - The provider needs no new attribute kind: a `json` field inside a nested list object is a string attribute in that object, and `flowmodel`'s reader parses it where it already parses a top-level `json` field. - The studio's inspector shows the expression as the JSON editor it shows `ViewData` in, inside a per-step list. All of `RoutingCriteria` as `json` was not taken: it costs least, but it makes the expiry and the step list opaque to the schema, to HCL and to the inspector, for no gain, since the step is a plain object. A depth-limited shape was not taken because the depth is arbitrary, each level is a copy of the same three fields in the catalog and in the provider's schema, and a console-built expression one level deeper than the limit silently becomes generic. A `number` kind was not taken because the typed shape it would serve is the one the provider cannot hold, and a kind costs a `catalog.test.ts` mutation, an HCL rule, a provider attribute and a studio field for a single value inside a value the contract already leaves opaque. ## Consequences The expression is validated by the FlowDoc schema and the builder, not by the Terraform schema; a wrong `ProficiencyLevel` in HCL is caught at plan time only because the provider validates the computed `flowdoc` against the schema, not by Terraform's own type check. That is the trade a `json` kind makes everywhere it is used. `content` is parsed JSON in every reader, so the spelling `2.0` becomes `2` on the way through TypeScript and Go alike, and no catalog kind changes that. Whether the service accepts the integer spelling is in D07's evidence list (an integer beside 0.5, 2.5 and 5.5); if it refuses it, the builder's helper cannot help, and the finding is recorded against the type rather than solved in the format. `dynamic` is existing vocabulary on scalar kinds (an `enum` or `integerString` the page also lets a JSONPath stand in for); on a `list` it is new, and D01 adds that case to `catalog.test.ts` and to the provider's strict parser before the re-vendor. D07's fixture holds a nested `AndExpression`, an `OrExpression` if the console writes one, and the JSONPath form.