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 (<name>.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 <name>.flow.tf companion instead of a
<name>.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
integerparameter holding a JSONPath makes the whole actiongeneric, because the provider's attribute for that kind is a number (contract rule 11), and anintegerStringa JavaScript number cannot hold exactly (-0, anything past 2^53) stays a string.packages/hcl/src/document.test.tsholds both, and thenumbersround-trip case holds the ordinary forms. - Positions come back as integers. A fractional position (the console exports
them) is rounded on write, and a
positionblock 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.arnin a reference field is rewritten to a key plus arefsentry on the next save; the provider refuses the same text asREF_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
(
terraformErrorin those cases'case.json). - A literal ARN in
refsis 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_hashhashes the content without Metadata, so moving a block on the canvas changescontentbut not the hash a module version is keyed to, and the provider's drift check ignores Metadata too (the provider'sTestLayoutIsNotDrift). - Import names keys after the live resources. A flow read from an instance
with no
refsin 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'sTestImportRecoversBlocksAndBindingsandTestDriftShowsAsBlocksAndIsCorrectedhold the two.