fjall CLI, the @fjall/* infrastructure constructs, and the fjall.io control plane.
About version numbers. The CLI and the infrastructure constructs ship as one deploy engine on a shared version line, so a change to what a construct synthesises is a major even when no command, flag, or output moved. You are not expected to track them. Your application pins its constructs, and the engine refuses to run against infrastructure newer than itself rather than deploying something different from what you wrote. Upgrading the CLI never locks you out of redeploying an older application. See Engine compatibility.
Advisory for fjall 39.0.8 and earlier: adopting a live Route 53 zone deletes each consented record before the domain stack deploys, a failed deploy recreates none of them, and the import can declare records another stack owns and republish a multi-string TXT record broken
A record takeover deletes live records first, and a failed deploy recreates none
In fjall 39.0.8 and earlier, the firstfjall domain deploy of an adopted Route 53 zone releases each record you consent to with --remediate '<name>/<TYPE>'=recreate by deleting it from the live zone, one record at a time, before the CloudFormation deploy starts. Each record is absent from DNS until the domain stack creates it again, and a resolver that asks in that time caches the absence for up to the zone’s negative TTL: 900 seconds with Route 53’s default SOA. If the stack’s creation fails, CloudFormation rolls it back, the deleted records are not recreated, and Fjall keeps no copy of them.An application can make that failure certain. The domain stack exports <zone>-hosted-zone-id, the zone with its dots as dashes (example-com-hosted-zone-id), and an application stack that binds the zone with HostedZoneFactory.import exports the same name. CloudFormation refuses the domain stack’s creation when the name is already exported in its account and region, and it refuses at execution, after the takeover has deleted the records.fjall domain import <domain> does not check who owns a record, with --agent, with --non-interactive or with neither. It declares records another live CloudFormation stack owns along with the rest, such as an application’s alias record or the DNS validation CNAME of a certificate CloudFormation manages, and after a takeover both stacks claim each one, with the domain stack holding a literal copy that does not follow the other stack’s changes. Only the guided import, fjall domain import with no domain argument in a terminal, classifies ownership and leaves such records unselected. It reads only stacks that carry a fjall: tag or a name starting Fjall, in one region, so it can still select the validation record of a us-east-1 certificate for CloudFront.A TXT record held as several strings, such as a 2048-bit DKIM key ("<part 1>" "<part 2>"), imports from Route 53 with the quote characters between its strings kept in the value, and the deploy publishes that broken value. A BIND import joins the strings with a space instead, and ends a string at an escaped quote.Until a fjall release fixes this (no released version does yet):- back up the zone, and prepare its restore, before you consent;
- check
list-exportsfor<zone>-hosted-zone-id, and if an application exports it, bind that application’s zone withdomainConfig.managedDomain: { zoneName, hostedZoneId }in place ofHostedZoneFactory.import, and deploy the application before you plan the domain; - remove from
infrastructure.tsevery record another stack owns, and treat every recordfjall domain records listmarksunknownas owned until you have checked it; - compare every declared value with the live record, and declare a multi-string TXT value as one string, its parts joined with nothing between them;
- declare a subset of the records first: the takeover never releases a record
infrastructure.tsleaves out.
Advisory for fjall 39.0.7 and 39.0.8: a command that rewrites infrastructure.ts whole writes a database's databaseName back as an empty string when the file gives it as a constant, which replaces the database at the next deploy
A rewrite of infrastructure.ts can blank databaseName and replace the database
In fjall 39.0.7 and 39.0.8, a command that rewrites infrastructure.ts whole writes a database’s databaseName back as databaseName: "" when the file gives it as a constant or any other expression rather than a string literal (databaseName: DB_NAME). A databaseName chosen per environment (databaseName: isProd ? "primary" : "primary_dev") is written back as its last branch, for every environment.These rewrite the file whole:- the interactive add flow, when it adds a service, a CDN, a compute, a network, a tunnel or a ClickHouse database, and when it adds a database, a bucket or a proxy that it cannot add in place;
- the
@fjall/generatorplan API (generateResourceChange).
fjall add with a resource type and properties, and fjall modify, edit the file in place and leave databaseName as written.The database construct names its RDS instance or Aurora cluster from databaseName, so the next fjall deploy of a rewritten file replaces the database with a new, empty one.Until a fjall release fixes this (no released version does yet):- write each
databaseNameas a string literal (databaseName: "primary"); - change databases with
fjall modifyorfjall add <type>, which edit the file in place; - after any command that rewrote
infrastructure.ts, compare everydatabaseNamewith the deployed one before you runfjall deploy, and restore any that changed.
databaseName the same way. This entry first named 39.0.9 as the fix, but no released version fixes it yet.fjall.io: the asset scan reads each customer-managed KMS key's policy, grants and last use, and the share list of each manual RDS snapshot near a year old
The asset scan makes four more read-only calls
From 2 October 2026, the asset scan makes four read-only calls in your connected accounts that it did not make before, so they can appear in your CloudTrail:kms:GetKeyPolicy,kms:ListGrantsandkms:GetKeyLastUsage, on each customer-managed KMS key that is enabled or disabled;rds:DescribeDBSnapshotAttributes, on each manual RDS snapshot within a day of a year old.
ReadOnlyAccess and SecurityAudit policies grant them through kms:Get*, kms:List* and rds:Describe*. A key whose own policy refuses the scan role is recorded as unread, never as unused.fjall 39.0.2: a terminating instance keeps its data volume until its tasks have stopped, ClickHouse formats only a blank volume, and ECS capacity waits for its capacity provider
A terminating instance keeps its data volume until its tasks have stopped
This affects an EC2 capacity whose Auto Scaling group carries apersistentDataVolume, which every ClickHouse database uses. Through fjall 39.0.1 the terminating lifecycle handler judged the instance idle as soon as ECS reported no running tasks. ECS stops counting a task as running the moment it leaves RUNNING, but a STOPPING container is still writing. The handler then detached the volume from under that container.From 39.0.2 the handler never detaches the volume.- It sets the container instance to DRAINING and waits until every task on it is STOPPED.
- A task still running after 240 seconds is sent
StopTaskand given another 150. - The handler then deregisters the instance and lets the termination continue.
- The volume is released by the instance’s own shutdown, which unmounts it first. The launching handler waits up to 480 seconds for that release before it attaches the volume to the new instance. If the release does not come in time, it abandons the launch and the Auto Scaling group tries again.
drain_proven when every task has stopped. It logs drain_unproven when it could not prove that, and the termination continues either way. On a cluster with an alerts topic, a metric filter counts drain_unproven and an alarm notifies the topic.Upgrading replaces a ClickHouse instance once
39.0.2 changes the ClickHouse user data, so the first deploy on it replaces the instance through the Auto Scaling group’s instance refresh. The refresh terminates the old instance before it launches the new one, so plan for the same short outage as any ClickHouse instance refresh.The Auto Scaling group now depends on both lifecycle handlers. The update therefore installs the new handlers, their permissions and their 900-second hooks before the refresh starts, and the new handler drains the old instance. On 2026-09-26 we checked this against CloudFormation’s stack events and CloudTrail. We upgraded a 39.0.1 ClickHouse database in place to 39.0.2, with no other change. Both handlers and the terminating handler’s permissions finished updating before the Auto Scaling group began its update. The old task stopped before the handler proved the drain, noDetachVolume call was made, and every write the host had synced before the refresh survived it.ClickHouse formats a data volume only when it is blank
At boot, ClickHouse’s user data mounts the data volume whenblkid -p finds an ext4 filesystem on it. It formats the volume only when blkid -p finds no signature, the volume reads back, and neither the primary ext4 superblock nor its first backup carries the ext4 magic. In every other case it refuses to mount or format the volume, and the instance fails its boot instead of starting ClickHouse on the wrong disk. When the refusal names a superblock, e2fsck -b 32768 <device> repairs a filesystem whose primary superblock is damaged.Through 39.0.1 the check formatted the volume whenever blkid’s output did not mention ext4. That output is empty for a device blkid could not read, as well as for a blank one.The same user data now spells Docker’s process limit LimitNPROC=infinity. systemd rejected the old spelling, unlimited, and left the limit at its default.ECS capacity waits for its capacity provider before it registers
ECS binds a container instance to a capacity provider only if the provider exists when the instance registers. CloudFormation creates the provider after the Auto Scaling group is complete, so a first deploy’s instances could register first. They were never bound, and a service placing tasks through the provider never placed one. The first deploy failed when the service’s circuit breaker tripped, and no stopped task said why.From 39.0.2 every non-Windows ECS EC2 capacity’s user data holds the instance’s first registration until a capacity provider naming its Auto Scaling group is ACTIVE on the cluster. It waits up to 600 seconds, and registers anyway after 120 seconds of failed checks. It prints lines startingfjall-ecs-capacity-provider-gate: to the console output. The instance role gains ecs:DescribeClusters on its own cluster and ecs:DescribeCapacityProviders.A failed deploy names the failure that started the rollback
When a failure starts a rollback and the rollback then fails as well, the deploy now reports the first failure, not the rollback’s. It reads every failed event of the operation, oldest first, and names the earliest one that CloudFormation did not cancel because another resource failed. While CloudFormation rolls out an ECS service, the deploy also prints that service’s own ECS events. A task ECS cannot place never stops, so those events are the only record of why such a service never started.Upgrading to fjall 39.0.2
The disaster-recovery tag lands on backup sources only, enrolled buckets publish the events their point-in-time window needs, ECS services resolve by contract, KMS encryption names its key, and a child that runs customer code declares an environment policy
The disaster-recovery tier tag lands on backup sources, and the enterprise record is monthly
An AWS Backup selection is built from a tag and carries no resource-type filter, so the set of resources stampedfjall:disasterRecovery:tier is, exactly, the set AWS Backup will try to protect. Through fjall 38 that tag was a global tag on the application, which reaches every taggable construct in the tree: VPCs, subnets, security groups, roles, log groups, secrets, an RDS subnet group, a read replica, an Aurora cluster’s member instances, an unversioned bucket, and — through a launch template — every instance an Auto Scaling group launched and its root volume. AWS Backup cannot protect most of those. For the ones it can, protecting them was wrong (the primary’s own recovery points already hold that storage), impossible (every job against an unversioned bucket fails, for ever), or paid for a copy of something an AMI and user data rebuild.From fjall 39 the tag is stamped at construction, by the five constructs that own a backup-eligible resource. The eligible types are the ones AWS Backup supports: S3 buckets, RDS instances, RDS/Aurora clusters, DynamoDB tables and EBS volumes.A versioned bucket is a backup source unless it says why not. AWS Backup requires versioning, so enrolment follows versioning rather than the tier. The standard tier leaves versioning off, so an app on it enrols no storage of its own and its plan covers the database and volume types instead. A bucket a construct versions explicitly is a source at every tier.That leaves versioned buckets no plan should hold — the VPC flow-log bucket, the CloudTrail bucket, the AWS Config delivery bucket — each written into continuously by an AWS service under its own lifecycle rules. They now carry fjall:s3:backup-exempt, whose value names why (log-delivery), so the answer is on the bucket rather than inferred from its absence.Two synth warnings come with it. A versioned bucket carrying no tier and declaring no exemption warns (s3:backupTierUnresolved) — the tag is what a plan selects on, so a construct that forgot to thread the tier dropped its bucket out of every plan with nothing to say so. And s3:backupTierNotEnrolled now stays quiet when the bucket already records why it runs unversioned with versioningExemption, which is the cure that warning names.The enterprise tier’s long-term rule is now monthly. It was hourly, for seven years. Inside the 35-day window the continuous rule already gives second-level recovery for the three types AWS supports it on, so for those the hourly rule bought granularity nobody asks for beyond 35 days at 720× the job count — and 24 RDS recovery points a day would exhaust an account’s manual-snapshot headroom in under four days. Cold storage moves from day 365 to day 90 (AWS’s minimum), and each rule now carries one copy destination by role: the continuous window to the DR region, the seven-year compliance record to the compliance account. Sending both rules to both vaults had doubled the copy volume to buy a second copy of the same thing.The three types with continuous backup are S3 buckets, RDS instances and Aurora clusters. The two the enterprise tier also enrols are not on that list: an EBS volume and a DynamoDB table get no point-in-time recovery from a continuous rule. AWS degrades rather than fails, so what the continuous rule buys those two is one daily snapshot kept 35 days — a day’s granularity, not a second’s. Beyond the window they move from hourly to monthly, and that is a real reduction for those two types. It is the deliberate one: a seven-year record at hourly granularity is a compliance artefact nobody restores from.
fjall:backupPlan:recoveryPointsNotCopied fires when neither rule has a destination. fjall:backupPlan:noCrossRegionCopy fires when the plan copies to the compliance account but nothing crosses a region — the configured disasterRecoveryRegion is the account’s own, so losing the region still loses every recovery point. Both name the cure.What to expect on the next deploy
If you rely on the tag for your own automation, read it from the five supported types. A sweep that matched the tag on any taggable resource will now match fewer resources — that is the fix, not a regression: the wider match was never a statement about what was being backed up.See the enterprise pattern and the resilient pattern.
A bucket in a backup plan publishes the events its point-in-time window is built from
AWS Backup does not watch an S3 bucket to build its continuous backup. It subscribes to the bucket’s object events on the default EventBridge bus and replays them to keep the point-in-time window moving. The bucket-level setting that publishes those events is off unless something turns it on, and through fjall 38 nothing did.That is the worst shape a backup defect can take. AWS Backup’s managed rule stays enabled and receives nothing. The plan reports healthy. The snapshot rules keep succeeding. The initial full backup is real, so a continuous recovery point exists and looks right. Only the thing the resilient and enterprise tiers are sold on — restore to any second inside the last 35 days — quietly stops tracking after that first backup, and AWS documents the failure in those words: “no notification, alert, or backup job failure is generated by AWS Backup”. AWS also says it cannot detect the setting being off, so nothing downstream could have caught it.From fjall 39 a bucket Fjall enrols in a backup plan publishes its object events. The condition is exactly the one that decides the bucket is a source — versioned, resolves a tier, declares nobackupExemption — read once and used for both, so a bucket cannot be selected into a plan whose events it does not publish. An explicit eventBridgeEnabled: false still wins: turning it off is choosing snapshots.What changes on an existing deployment. Each enrolled bucket begins publishing every event type S3 sends to EventBridge to the default bus. For what those events cost, see EventBridge pricing. If you already run EventBridge rules matching aws.s3 object events without a bucket filter, they will now also match these buckets. A stack holding at least one enrolled bucket also gains CDK’s notification machinery: one Custom::S3BucketNotifications per enrolled bucket and one shared handler Lambda per stack, scoped to s3:PutBucketNotification.Rollback is eventBridgeEnabled: false on the bucket: snapshot backups only, and a continuous recovery point that does not advance.ECS services are found by their exact export names
fjall services, fjall rollout, fjall deploy --deploy-only, an image-tag rollback and the post-deploy endpoint reconcile all find an application’s ECS services through the …DeployableService exports its Compute stack emits. Through fjall 38 the engine found those by listing every export in the account and region and matching the app name against the name — first as written, so billing-api never matched the BillingApi… its own stacks emit and fjall services exited 1 with “Deploy the application first” against a healthy deployment; then, in fjall 38’s interim fix, on PascalCase token boundaries, which still matched a sibling whose name contained the app’s.From fjall 39 the search is a contract. The constructs and the engine build the same names from the same inputs through one set of functions. The engine describes the app’s own Compute stack and accepts an output only when its export name equals the name rebuilt from the service ARN it carries. Nothing is matched by substring or token, and no export outside the app’s stack is read.Four defects go with it:- Every service is addressed on its own cluster. The resolver used to take the cluster of the first export for every service, and one
DescribeServicesspanning clusters is a call ECS rejects whole. Describes are now issued per cluster in batches of ten, and services ECS reports underfailuresare surfaced by name instead of vanishing. - A ClickHouse database no longer advertises its cluster as deployable, so an app’s restart never rolls its own database.
- A failed read is a failure. A CloudFormation error used to come back as “no services found”. The resolver now fails closed: a read failure is reported with its cause, and an absent Compute stack is reported as absent.
- The partition survives. The rebuilt cluster ARN kept
awswhatever the service ARN said.
What to change
Application Compute stacks emit the same export names as before, so nothing needs redeploying for the resolver: upgrade the CLI and re-run the command. Apps last deployed on constructs 2.x–7.x still resolve.fjall services <app> --jsonno longer carries a top-levelclusterArn; each entry carries its ownclusterArnandclusterName.- A manifest written by constructs older than 0.87.0 records no
clusterName, so a code-only deploy or rollback refuses with the cure — re-synthesise with the current constructs — instead of guessing by name.fjall deploystill deploys, but warns that artefacts are recorded without task-definition ARNs. - A narrowed SSO permission set or SCP needs
cloudformation:DescribeStackson the app’s Compute stack (and its Network stack for the VPC CIDR capture). Do not dropcloudformation:ListExports— the ECS resolver no longer calls it, but the ECR repository-name lookup thatfjall buildandfjall deployrun on every invocation still does. - The not-found messages say which. A missing stack, an empty stack and a refused read used to read alike; they now name the case, and a refused read reports its cause.
fjall rolloutconfirms the services exist before it rolls any of them. Through fjall 38 it warned past a failed drift probe, rolled the healthy services in name order and died on the missing one with “Service has no task definition” — a partial restart reported as a task-definition problem.- A cancelled read is never an answer. Three read paths used to return the rows they had reached when the caller’s signal aborted mid-walk, so a short walk reported a clean verdict for services nothing had asked about. All three now fail, naming what they never reached. A name absent from a walk that completed is still proof of absence.
- A drift verdict is three-valued.
stale: booleanbecomesverdict: "stale" | "current" | "unknown". A service whose live task definition could not be read used to report “up to date” — the worst possible answer after a rotation.
A queue or bucket asking for KMS names the key
Two construct props asked CloudFormation for KMS encryption without naming a key:SQSQueue’s encryption: "SSE_KMS" with no masterKey, and S3Bucket’s BucketEncryption.KMS with no encryptionKey. Neither fell back to the AWS-managed key — CDK minted an AWS::KMS::Key of its own for each, and that key is the worst shape a key can have. It is retained while the resource that asked for it is destroyed, so it outlives every stack that referenced it. It carries no alias and no tags, so every Fjall teardown sweep is blind to it and it bills $1–3 a month for as long as the account exists. And its key policy is root-only: for a queue, an EventBridge rule or S3 notification targeting it needs kms:Decrypt on that policy, and SQS drops what the key refuses, silently.From fjall 39 the shape is refused at the vocabulary the caller typed.Queues. encryption: "SSE_KMS" with no masterKey now takes the stack’s shared encryption key, with the SQS-sending service principals admitted to it. The key is aliased, tagged and swept like every other Fjall key. Passing masterKey still brings your own key. Passing masterKey beside "SSE_SQS" or "NONE" now throws — fjall 38 warned and then discarded the key, which is indistinguishable at synth from a queue that honoured it.Buckets. BucketEncryption.KMS now needs an encryptionKey, and the Storage projection’s encryption: "KMS" needs a kmsKeyArn. A kmsKeyArn on its own implies "KMS"; one beside "AES256" throws rather than being ignored.The import lane refuses rather than re-points. Adopting a live bucket whose encryption the plan cannot state — aws:kms with no key id, which is the AWS-managed aws/s3 key — no longer silently re-points the bucket at a Fjall key on the next deploy.A bucket that never opted in is unaffected: AES256 remains the default. A queue on SSE_KMS keeps working and moves onto the stack key on the next deploy.Every child that may run customer code declares an environment policy
The CDK CLI executes your owninfrastructure.ts. Through fjall 38 the environment that child received was the parent’s, minus a deny-list of exec-hijack names (NODE_OPTIONS, LD_PRELOAD, HOME). A deny-list of hijack names strips no secret — exactly right on your own machine, where the child is your code running as you, and exactly wrong in a multi-tenant worker, where the parent holds credentials the child must never see.From fjall 39 that choice is explicit. ChildEnvPolicy has two shapes: { kind: "inherit", reason } is today’s behaviour, unchanged, with the reason stated at the call site rather than defaulted into; { kind: "isolate" } is an allow-list, where only the names a child demonstrably needs survive. isolate additionally refuses a never-list led by AWS_CONTAINER_CREDENTIALS_RELATIVE_URI — a child that reads one fetches the parent’s task-role credentials.The CLI declares inherit, so there is nothing to change and no behaviour change: it runs your infrastructure.ts on your machine under your environment, and parameterising a CDK app from AWS_PROFILE or your own variables is the supported shape. @fjall/deploy-core embedders get a required childEnvPolicy on DeployParams and DestroyParams; tsc names every site.--target verifies the account it authenticated into
An explicit --target <name> resolves the target to an account and then opens a session for it. Through fjall 38 nothing compared the account that session reached with the account the target names. The credential cache is keyed on profile name and region only, so a target that had since been re-pointed could serve yesterday’s account under today’s name — and fjall secret set --target wrote a secret there.From fjall 39 the binding is checked, using the identity call authentication already makes. It refuses on a mismatch (“refusing to act in an account you did not name”) and on an unconfirmable identity (“refusing rather than acting on an unverified account”). Both name the cure: fjall connect, then fjall target list.The check sits before the command touches AWS, and covers every fjall secret leaf, fjall domain deploy, and the fjall domain verbs that establish a domain’s home account. There is no opt-out flag — a flag that skipped it would bring back the silent wrong-account write this release removes.See fjall target.A flag that asks for a number refuses what is not one
Every numeric flag shared one coercion, a bareparseInt. It refused nothing: --limit 5abc became 5, --hours 1e3 became 1, --local-port abc became NaN. Two reached somewhere that mattered — NaN survived the tunnel’s port default and crashed inside listen, and doctor --max-age-hours -1 put the staleness cutoff an hour in the future, making every cdk.out directory stale including one an in-flight synth was still writing.From fjall 39 each numeric flag refuses at the flag, before the command runs:A script passing a well-formed number is unaffected. In every other case fjall 38 acted on a value that was not the one supplied, so the refusal reports a defect the earlier release hid. Known residual:
fjall assets --take is still parsed in its handler and keeps the old permissive behaviour for now.A scheduled task’s network fields either take effect or are refused
EcsScheduledTaskConfig has declared securityGroups and subnetSelection since May. Neither was ever wired — a value set on either travelled no further than the interface that accepted it, with nothing said at synth, at deploy or at runtime.Both fields are removed in fjall 39, rather than wired, because the only mode under which they could take effect is one these tasks cannot run in. A scheduled task’s connections are wired to the cluster’s EC2 capacity security groups — the groups the instances wear — which is correct for bridge and host networking. Under awsvpc the task gets its own ENI, which wears none of them: every IAM grant would still land and every network path would not.So networkMode: NetworkMode.AWS_VPC on a scheduled task is now refused at synth. Omit networkMode to run on the instance ENI, or set NetworkMode.HOST. Reachability is governed by the cluster-level securityGroup.Supplying logGroup together with logRetention is also now refused, naming the retention that would have been lost. ClickHouseDatabase applications need no change: the pattern stopped supplying the field on your behalf and the synthesised template is byte-identical.An infrastructure.ts setting either field stops compiling at the pin bump. Delete the line — it has never had an effect. A typecheck error is a better signal than the silent drop it replaces.fjall restore --name names the database, or says why it cannot
-n, --name was registered on fjall restore and read by nothing. Every lane accepted it and threw it away, so fjall restore rds --recovery-point <arn> --name orders-restored --non-interactive restored under whatever name AWS Backup derived, and said nothing about it.Fixing that surfaced a second defect underneath: AWS Backup will not start a restore job without the restore metadata the recovery point was taken with, and both non-interactive lanes passed none — so a scripted RDS restore could not succeed at all, whatever it was called.From fjall 39:--namereaches AWS Backup on every lane, becoming the restored instance’sDBInstanceIdentifier. The interactive picker seeds its prompt with it.--nameis refused fors3,ebsandec2. Those are named by AWS Backup from the recovery point and have nowhere to put one.--nameis checked against AWS’s identifier rules at the flag, so a name AWS would reject is refused before the restore is costed.--backup-vault <name>is new, defaulting to AWS’s ownDefault, and the failure names the vault that was actually read.
fjall restore rds that failed in fjall 38 with a complaint about missing restore metadata now succeeds.See fjall restore.A withheld destruction returns its per-consent verdicts
When the destruction gate withholds a deploy it has already judged every consent supplied: matched, naming a resource the ticket does not carry, or naming the right resource with a verb that finding does not allow. Through fjall 38 that judgement existed only as prose in the log stream, so no caller reading the result could answer the question a refused caller actually has: which consent failed, and why.From fjall 39 the table is on the result asdestructionConsentReport — one verdict per supplied consent in the order supplied, the ticket names no consent covered, and a digestMismatch when the plan changed between mint and apply so every consent is stale.The field is present only when consents were actually evaluated. Absent means no consent was supplied. It never means every consent passed.
consentVerdicts, uncoveredResources, consentDigestMismatch and both digests. Before retrying a refused ceremony, read consentVerdicts: unmatched means the name is not on the ticket, wrong-verb means the verb is illegal for that finding, and a digest mismatch means every consent must be re-minted.@fjall/deploy-core embedders that relayed the engine’s warn lines to show why a destruction was withheld: those lines are gone. Read the structured fields, or pass them to the renderers, which are unchanged and still exported.See destructive changes.A tunnel can outlive the command that opened it
fjall tunnel --background now really detaches. Through fjall 38 the flag was registered and acknowledged but the session was always attached: piped stdio, no detachment, killed with the CLI. It now starts the session in its own process group with its stdio on a log file, returns once that session reports it is listening, and leaves it running after the command, the shell and the terminal have gone.Two verbs manage what that leaves behind. fjall tunnel stop [name] ends one — the only one, or the named one — checking the recorded process still belongs to a tunnel session before it sends anything. fjall tunnel status lists what is running, probes each local port, and prunes records of sessions that are gone. Neither needs a Fjall session or AWS access.One detached tunnel runs per app and database, named <app>-<database>, with its record under ~/.fjall/tunnels/. A second start while the first is alive is refused and names the running one. A local port already in use is refused before anything is spawned, and so is a record that cannot be read — an unreadable record is not proof that nothing is running. Nothing is left half-started: when a session cannot reach a listening state, Fjall stops it, removes its record and keeps its log.fjall tunnel without --background behaves exactly as before. A script that passed --background in fjall 38 and relied on the command blocking until Ctrl+C will now return as soon as the tunnel is listening, and should call fjall tunnel stop <name> when it is finished. fjall tunnel --agent now requires --background.See fjall tunnel.Installing the CLI no longer installs Nx
Through fjall 38@fjall/cli listed @nx/devkit as a runtime dependency, and @nx/devkit requires nx as a peer — so every install of the CLI also installed Nx: its dependency tree, its postinstall script, and its exact version pins. The CLI never ran any of it; it imported two TypeScript interfaces for its generators, and no compiled file referenced either package. One of those exact pins carries a high-severity denial-of-service advisory (GHSA-7w5x-hrqm-74c2) that every released Nx still pins.fjall 39 declares the contract in the CLI itself and drops the dependency. Installs are smaller and no longer run Nx’s postinstall. A project that used nx only because the fjall install happened to bring it in must now declare it itself.The fjall package carries its own command surface
An installed fjall now ships .fjall/cli-inventory.json — the machine-readable record of everything that exact version accepts. Every command path and alias, whether each is invokable or only a group, every option with its spellings and whether it takes a value, and every positional argument with its name, whether it is required, whether it is variadic, and its permitted values where it constrains them.Anything checking a command line against a released CLI — a docs build, a lint over a runbook, a wrapper script validating its own arguments — can read it out of node_modules/fjall/.fjall/cli-inventory.json offline, exactly, at the patch. It is a generated artefact, not an API: schemaVersion says which shape it is in, and a version that predates fjall 39 simply does not contain it.The published package is ~200 KB larger. Nothing else changes.An unregistered application is told what its own verb cannot do
Every caller that asked Fjall to resolve an application it had never heard of got the same sentence:Application 'x' not found. Deploy it first. That reads correctly for a deploy. It reads oddly for a read, and for the remove family — fjall apps deregister, fjall dev down, the dev substrate teardown — it inverted the operator’s intent, instructing them to create the thing they had just asked Fjall to destroy.Absence still means one thing: the application is not registered with fjall.io. What to do about it depends on the verb, so the message now does too. A read says the application has no records to read, and that an application registers on its first deploy. A remove says there is nothing to remove. A deploy is unchanged.Message text only — no command, flag, exit code or output shape changes. A script matching on the literal Deploy it first. still matches fjall deploy and fjall dev up, and no longer matches the read and remove verbs, each of which now states its own situation.The MCP destroy tool has no way to answer for the user
destroy is the MCP surface’s one irreversible tool without a plan pair, so it takes consent in the handler: it asks the host and spawns the CLI only on a yes. Through fjall 38 it also accepted skipConfirmation, a boolean meaning “the user has already agreed” — which did double duty, satisfying both the host prompt and the CLI’s own non-interactive guard, so one boolean bought the entire teardown unattended from a tool call. And the server handed it out: when a host could not show a prompt, the refusal named the cure in as many words. A cure an agent can serve itself is not a confirmation.fjall 39 removes the input; a caller still passing it gets a parse failure naming the key. Whether the question can be put at all is now read from what the client declared at initialise rather than guessed from an error shape. Everything that stops the question being answered is one verdict: the user never answered, and calling destroy again asks once more. Silence is not consent.A host that cannot elicit no longer runs destroy through the MCP at all, by design, and says so. Nothing about the CLI’s own destroy changes.Upgrading to fjall 39
fjall-deploy-action@v29, the Buildkite plugin at #v29, and cli-version: "39". See CI/CD and Engine compatibility.Output streams split, agent mode is asked for rather than detected, CloudFront logging moves to v2, EventBridge shares one queue grant, and the retained backup vault is re-owned
stdout carries the result, stderr carries the conversation
Through fjall 37 the CLI wrote almost everything to stdout: progress lines, step headers, summaries, hints and failures.fjall secret get DATABASE_PASSWORD > value.txt wrote the error message into the file when the read failed, and $(fjall secret get DATABASE_PASSWORD) could not tell a secret from a sentence without checking $? first.From fjall 38 the split is by what a line is, not by which command wrote it.A failed command leaves stdout empty, so read the exit code rather than the text. The one carve-out is a protocol the caller asked for: under
--agent, --mcp-protocol or a --json / --output json document, a failure may be a document on that stream, because an empty stdout is not a parseable answer.Several commands now hand their answer back as the result:What to change
- A script reading the minted token off stderr becomes
FJALL_API_KEY=$(fjall token create --name ci-probe --scope read --expires 1d).@fjall/utilno longer exportsTOKEN_STDERR_PREFIX. - A script parsing progress or summary text off stdout reads stderr instead. The text is unchanged, only the stream.
- A script that captured stdout and searched it for an error string reads the exit code instead.
- A mistyped command puts its usage on stderr.
fjall deploiused to fill a captured variable with the whole help screen. - Redirecting one stream no longer loses the other’s colour.
fjall deploy api | tee outkeeps coloured progress on the terminal, andfjall deploy api 2>deploy.logkeeps the log free of escape sequences.
Agent output is asked for, never detected
Through fjall 37 the CLI decided whether to emit TOON by reading the machine it ran on: seventeen environment markers (CLAUDECODE, CURSOR_TRACE_ID, CODEX_SANDBOX, GEMINI_CLI, AI_AGENT and the rest) behind a TTY check. The same script produced parseable output on one host and prose on another, with nothing in the command line to say which.From fjall 38 --agent is the only way to get TOON, and it is the only input to the decision. --no-agent is deleted with it, and commander rejects the name, so a script still passing it fails loudly. A human running Fjall inside an agent session now gets human output.Thirteen command paths are human-only leaves. They refuse --agent, --budget, --fields and --full before the command runs, and the refusal names what to run instead: releases, services, rollback, rollout, reconnect, accounts, doctor, deployments, dev, dev substrate, agent, agent install-hooks and agent uninstall-hooks.Three answers now ride the agent result block verbatim, neither masked nor thinned by --budget: value on fjall secret get, token on fjall token create and capturedPolicy on fjall unlock bucket. Through fjall 37 the envelope’s credential masking ran over them, so a secret shaped like an AWS key came back ***, which reads as an answer and is not one.See Agent mode.target get and target set name the target target
The agent blocks for fjall target get and fjall target set carried the active target’s identifier as name, while deploy, aws exec and both commands’ own human summaries spell it target, which is also the spelling every command accepts it under as --target.Both single-record blocks now answer target. fjall target list keeps name, because each row is a target and the identifier is the row’s own property.A --fields projection naming name on target get or target set must name target instead. An unknown field is refused before the command runs, so this fails loudly rather than returning nothing. target list --fields name is unchanged.See fjall target.CloudFront access logs move to standard logging v2
Through fjall 37 aCloudFrontDistribution, and so every Cdn, logged through CloudFront standard (legacy) logging: the distribution’s own Logging block, driven by enableLogging, logBucket and logFilePrefix. From fjall 38 both log through standard logging v2, which is CloudWatch Logs vended-log delivery to S3. Each logging distribution gets an AWS::Logs::DeliverySource, an AWS::Logs::DeliveryDestination and an AWS::Logs::Delivery joining them. The Logging block is never rendered.The three legacy props are gone from CloudFrontDistributionProps and BaseCdnProps, so a TypeScript caller fails typecheck. A JavaScript caller, a spread from a saved config or a generator template is refused at synth by name:Cdn pattern, and so on CdnFactory:enterprise on, standard and resilient off. An object turns logging on at any tier, each field defaulting on its own, with bucket defaulting to the stack’s shared access-log bucket, the one ALB access logs already use, prefix to cloudfront/<id> and format to json. The other CloudWatch formats are parquet, plain, w3c and raw. false is off at any tier. All three fields are create-only, because each keys the delivery destination’s name, so changing one replaces the destination.What to change
What the upgrade deploy does
CloudFormation drops theLogging block and creates the three delivery resources in one deploy. The distribution is not replaced. Objects legacy logging already delivered stay readable under the old cloudfront/<id>/ prefix, and new objects nest beneath the same prefix under <yyyy>/<MM>/<dd>/<HH>/. AWS documents up to twelve hours for a logging change to take effect and about four hours before delivery is reliable, so expect a gap between the last legacy object and the first v2 object rather than a broken configuration.CloudWatch Logs accepts CloudFront delivery resources in us-east-1 only. A CDN whose stack is already there hosts them beside the distribution. Any other region hands them to a new application-owned stack, ${app}UsEast1CdnLogging, which deploys after the CDN stack and is destroyed before it. A bare cdk synth of an enterprise-tier application outside us-east-1 now needs CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION exported, as the certificate stack already did.Cure once, then redeploy. The objects already in the bucket are unaffected:CloudFrontDistribution with enableLogging: true and no logBucket had CDK create a bucket inside the distribution construct, under CDK’s default Retain. Removing the prop removes that resource from the template and CloudFormation honours the retain, so the bucket stays with its objects, nothing deletes it and nothing writes to it any more. Keep it as an archive, expire its objects with a lifecycle rule, or empty it and remove it. To keep writing there, import it as logging.bucket and add the bucket-policy statement the synth warning names. The Cdn pattern always named the stack bucket, so this is the resource layer only.Replacing the bucket under an unchanged construct id needs two deploys. The delivery destination’s name carries a digest over the three create-only inputs, so changing the bucket, the prefix or the format gives the replacement a new name and CloudFormation can create it before deleting the original. A bucket built in the same application enters that digest as its construct path, because its physical name is only a token at synth. The one change the digest cannot see is therefore a bucket replaced under an unchanged construct id: the destination keeps its name, the new bucket makes CloudFormation replace it anyway, and the replacement collides with the original. Set logging: false, deploy, then bring the new bucket in on a second deploy.An imported bucket needs its grant added by hand, and cross-account buckets are not supported. A bucket Fjall builds gets the s3:PutObject statement the delivery writes through, scoped to the prefix and conditioned on the source account and the delivery source, plus a key-policy statement when its KMS key is managed in the same application. An imported bucket or key is not managed here, so synth warns and names the statement to add. A missing grant fails delivery silently rather than at deploy, which is why the warning names it rather than leaving you to find out from an empty prefix.Two more things to expect. Security Hub control CloudFront.5 reads the legacy Logging block, so from the first fjall 38 deploy every Fjall distribution reads FAILED on it, logging on or off, and the control is looking at the wrong place. The posture catalogue rule that records this as a known divergence follows in a later release, so until it lands read the finding as expected rather than as something to fix.CloudWatch Logs prices vended-log delivery to S3 per GB delivered, while the CloudFront documentation says standard-log delivery to S3 carries no additional charge. The two cannot be reconciled from the pages, so budget for the CloudWatch line until your own bill says otherwise. S3 storage and requests for the objects are charged either way, and parquet adds CloudWatch’s conversion charge.See CDN Factory and Enterprise pattern.EventBridge rules share one statement on a queue’s policy
An EventBridge rule reaches an SQS queue through the queue’s resource policy, not through an IAM role. Through fjall 37 every rule whose target or dead-letter queue was anSQSQueue added its own statement, about 430 bytes each. SQS refuses a queue policy over 8,192 bytes, so a queue shared by about eighteen rules stopped deploying with “Submitted policy is over max allowed size”, and nothing at synthesis said why.From fjall 38 rules delivering to a queue share one statement with the Sid EventBridgeRuleTargetGrant, and rules dead-lettering into it share another with the Sid EventBridgeRuleDeadLetterGrant. Each names every rule in its aws:SourceArn condition, so a rule costs its ARN rather than a statement. The two ids replace fjall 37’s per-rule AllowEventRule<id> ids on the dead-letter side, and they are load-bearing: without them @aws-cdk/aws-iam:minimizePolicies merges the two statements and one set of rules loses access.SQSQueue now also checks its queue’s resource policy, and its dead-letter queue’s, at synthesis. It refuses a policy over 20 statements or one that may reach 8,192 bytes, naming the queue. The byte count is an upper bound, so the refusal lands a few grants before SQS’s would: at 61 rules on the default bus, sooner on an application’s own bus because the bus name rides each rule ARN.- The next deploy rewrites each affected
AWS::SQS::QueuePolicyin place, including a queue with a single rule. No queue or rule is replaced, and the same rules are granted before and after. - A snapshot test or policy-as-code check over the queue policy reads one statement per grant instead of one per rule.
- An application that synthesised on fjall 37 can now be refused. Split the sources across queues, or lower
@aws-cdk/aws-iam.arnSizeEstimatewhen the refusal names references it could not size.
SqsSubscriptions beside the queue’s TLS-only statement.See SQS Queue, EventBridge and SNS Topic.A payload on an event-bus target is refused
app.addSchedule accepts an EventBusMessaging bus as its target, and through fjall 37 its JSDoc said a payload would reach the bus as the event. It never did. CDK’s EventBus target carries no input, so the rule forwarded the scheduled event unchanged and dropped the payload without a word.From fjall 38 the combination fails at synthesis with “payload is not supported on event-bus targets”, as ECS and log-group targets already do. Remove the payload to keep what deployed before, or target the consumer directly so it receives one.See EventBridge.External-records domains stop describing certificate-validation records
ADomain with registrar: "external-records" creates its ACM certificates with DNS validation and no hosted zone, so each validation record has to be created at the external registrar.Through fjall 37 the construct described those records itself, adding a Domain.manualRecords entry and a <Zone>ManualRecord<n> output triple naming _acme-challenge.<name> for every certificate name.Neither half was the record ACM asks for. _acme-challenge is the ACME label, the value was a placeholder, and the real record does not exist at synthesis, because ACM chooses it when CloudFormation creates the certificate. A record created from that output never validated, and the deploy waited until CloudFormation gave up.From fjall 38 the fabricated entries are gone. Domain.manualRecords and the <Zone>ManualRecord<n>Name, Type and Value outputs describe the declared records only, numbered by their position in the list, and fjall domain eject emits the same outputs. ManualRecord.reason is removed.- The next deploy of an external-records domain that declares certificates removes the validation outputs and renumbers its record outputs from
0. Outputs only, no resource is replaced. Anything reading<Zone>ManualRecord<n>Valueby index should re-read it. - Issued certificates are unaffected. Leave the validation CNAME you created in place, because ACM re-reads it at renewal.
fjall domain verifyno longer looks up nameservers for an external-records domain, and--show-delegationrefuses one, because Fjall creates no hosted zone for it.
Reading the real validation records
fjall domain deploy announces each record as a warning once ACM has chosen it, and repeats the records in its error message if the deploy fails while ACM is still waiting. fjall org deploy and the Fjall web app announce the same records during their Domains phase, which stalls until the records exist. fjall domain verify reports a certificate-validation verdict (issued, pending, unusable or unavailable), warns with each record still to create, and returns them as validationRecords for --fields projections. One record can validate several names, so a wildcard and its base name share one.The reads need cloudformation:DescribeStacks, cloudformation:ListStackResources, cloudformation:DescribeStackEvents and acm:DescribeCertificate. A denied read degrades to unavailable and names all four. Without the CLI, find the certificate ARN in the stack’s events and ask ACM directly:The retained backup vault is re-owned, not recreated
An account teardown retains the disaster-recovery vault and its KMS key, so a later account deploy meets a vault it does not own under a name it cannot change. Through fjall 37 the engine told theDisasterRecovery construct “adopt or create” with a boolean, fjallAdoptBackupVault.From fjall 38 the engine probes ownership before synthesis, with one DescribeBackupVault plus one ListStackResources on the account stack, and injects the verdict as -c fjallBackupVault=<json>. A vault that exists outside the stack is imported for identity, deployed with --import-existing-resources, re-probed and then converged in a second deploy inside the same account operation. This runs for production and compliance accounts with a DR region set.Four refusals can stop the deploy, each naming its cure:A re-owned vault does not apply the configured
vaultLock and declares no lock of its own, because Fjall never issues a lock call against a vault it did not create. When the live lock and the configured intent disagree, synthesis warns with fjall:backupVault:lockIntentNotApplied. The converge deploy sets the vault’s access policy to the DR copy grant and applies the stack’s tags, so a hand-written access policy is lost and belongs back as configuration.fjallAdoptBackupVault is gone from the CDK context and from DeploymentContext. A synthesis with no fjallBackupVault context is the create template, and a malformed value throws rather than defaulting.See Deploy Account and Engine compatibility.The MCP destroy tool asks before it spawns
Through fjall 37 the MCP server’s force and skipConfirmation inputs were coerced booleans, so the string "false" read as consent, and a bare destroy call forwarded --force --skip-confirmation with nothing asked. Both now default to false as strict booleans. A bare call asks the host to confirm through MCP elicitation and spawns only on proceed: true. A host without elicitation support gets ELICITATION_UNAVAILABLE and the cure, where it previously got a teardown.plan_app_scaffold narrowed its vocabulary in the same release. pattern is an enum of the deployable patterns, patternTier takes the pattern tiers (lightweight, standard, resilient, custom) rather than the infrastructure tiers it borrowed before, type is an enum of the six application types, database is a boolean rather than a string, and a staticsite plan without source, buildCommand and outputDir is refused at the plan boundary. The plan now carries type, database and containerPort through to the apply, which it previously dropped.plan_ci_setup pins the provider and application its own plan run resolved rather than the caller’s input, and refuses with CI_SETUP_PIN_UNRESOLVED when the run resolves neither or disagrees with an explicit request.See Agent mode.fjall secret addresses an account with --target
Every fjall secret leaf, and the bare fjall secret parent, now takes -t, --target <name>, the same flag fjall deploy, fjall destroy and fjall aws exec take. Before this the secrets lane had no way to say which account it meant. The namespace path is identical in every account, so reading a production value meant switching the checkout’s active target and remembering to switch it back.--region beside it, because a target names its own region.The answer now says which account it came from. Every secrets agent block carries account, region and accountName as default fields, secret list carries them as aggregates, and each interactive screen heads its step with the SSM path and the resolved account. Agent-mode suggestions carry --target when the invocation did.See fjall secrets.Hyphenated application names resolve their ECS services again
fjall services <app>, fjall rollout <app>, fjall deploy --deploy-only and an image-tag rollback find an application’s ECS services through its CloudFormation …DeployableService exports. The export name is built from toPascalCase(appName), which drops the hyphen, so billing-api emits BillingApiBillingApiServiceDeployableService. The matcher searched for the name as written, hyphen included, so no export of a hyphenated application ever matched.Against a healthy deployment, fjall services exited 1 with “No ECS services found”, fjall rollout refused with “No running ECS services found”, and a code-only deploy or rollback reported no changes, rolled nothing and still exited 0. Full fjall deploy, fjall status and fjall apps describe were unaffected.The matcher now searches for toPascalCase(appName) and requires every token boundary inside that form to be a boundary in the export too. Nothing to redeploy: upgrade the CLI and re-run the command. An application name carrying its own case boundary (WebApp) now needs that boundary in the export as well, which stacks scaffolded from the application name always carry.Two applications in one account and region can still collide when one name is the other with its separators removed (webapp beside web-app), or equals the other’s full leading token run (standards beside standards-api). The shorter name resolves the other application’s services.See fjall deploy and fjall releases.fjall apps deregister removes a registration
fjall destroy tears down an application’s infrastructure and leaves its registration standing, which is correct, because the registered entity is what a plan’s application limit counts. Nothing in the CLI could remove a registration, so an organisation at its limit could neither create nor free a slot.--fields check.The AWS stack is left standing, which the human note and the first success suggestion say outright. The agent receipt’s deregistered field reports the registration only. A scaffold run at the limit writes its files, prints the server’s refusal as a registration warning and leaves an unregistered application, and that refusal now names the cure.Re-install the agent skill after upgrading with fjall agent install-skill. The skill is stamped with the CLI version, and every copy installed before fjall 38 carries a recipe telling agents to destroy an unused application to get back under the limit.See fjall apps.Every destruction ticket prints its digest
A rendered destruction ticket now closes withticket digest: <digest>, the identity --destruction-consent-digest takes. It comes from the shared renderer, so no surface can omit it. Through fjall 37 a withheld fjall deploy, a withheld fjall drift repair and the domain consent ceremony each demanded a digest they never printed, and a --plan run was the only way to obtain one.This matters most in CI. An exit-4 job log already carries the digest, so a re-run can bind its consents without a second --plan pass.See Destructive changes and fjall drift.Resolving a target is a credential read, so it fails closed
fjall deploy --target, fjall secret get --target, fjall aws exec --target and fjall ci run resolve a target’s account id, role ARN and region out of the organisation config, then turn the answer straight into an AWS session. Through fjall 37 each of them fell back to an unbounded stale copy on disk when the API could not be reached, so a target removed or re-pointed months ago kept resolving to the account it used to name.From fjall 38 the refusal lives inside the resolution itself. A cached copy inside its five-minute TTL is still used, because that is the answer the API gave minutes ago, but an expired one is refused. The command stops and names the organisation-config read that failed.See Understanding profiles and fjall aws.Smaller changes
-a, --apponfjall aws execscopes the deploy session’s permissions to an application. It does not choose the account, and never did. Use-t, --targetfor that. The flag’s own help text says so now.- A stack the deploy state lists that the application no longer synthesises is probed and deleted in its own region. A
<App>UsEast1Certificatesor<App>UsEast1CdnLoggingstack is no longer reported as skipped and left behind. The destroy fails closed, having deleted nothing, when CloudFormation cannot confirm whether the stack exists, and names the stack, the region probed and theawscommand that answers. - The mid-operation stack guard probes each stack in its own region too, so a us-east-1 stack mid-operation stops a deploy or a destroy where it was previously read as absent from the target region.
fjall destroy --forceskips that guard. --outputonfjall accountsis refused by the parser for anything buthumanorjson. An unlisted value used to fall through to the human table.fjall apps detectalready refused one.BackupVaultProps.encryptionKeyis typedIKey, so passing theCustomerManagedKeywrapper no longer compiles. PassbackupKey.key. Supplying a key now also creates thealias/cmk/backupVault/<vaultName>alias on it, where previously it created no alias, and an existing alias of that name fails the deploy.- The add-resource wizard reached from a custom-tier
fjall create appadds Lambda, ECS and EC2 compute. Through fjall 37 it parsed its answers against a retired per-subtype option shape, so every compute request it built was refused.@fjall/generatorembedders:ECSGeneratorSchema,LambdaGeneratorSchemaandEC2GeneratorSchemaare removed in favour ofComputeGeneratorSchema. - The posture resource-type vocabulary and the codemod snapshot readers ship in
@fjall/utiland@fjall/generatorrather than being re-declared beside them. Additive, with no template, construct id or schema change.
The posture note follows what the workload builds, the pattern flow prints it too, fjall modify takes dotted paths, and the CLI requires Node 22.3
The posture note lists only what the workload builds
Through fjall 36 the review step’s Posture findings this tier produces listed every finding the chosen tier’s defaults could produce, whatever was being created. On the application flow that was right by coincidence — the application tiers deploy an ECS cluster — but it would have promised a Container Insights finding on a Payload site that never builds a cluster, so the pattern flow printed nothing. From fjall 37 the note is filtered to the resources the workload will synthesise: an application at the standard or resilient tier lists the Container Insights line alone (it creates no topic and, below enterprise, no bucket), a custom application prints no note until resources are added, and the pattern flow prints the note for the first time — a Payload site or a static site at the standard or lightweight tier listsS3_VERSIONING_DISABLED for its bucket, and a Payload site at resilient lists nothing. The fjall.io create-app wizard shows the same filtered note. See What the review step says about posture.fjall modify takes dotted paths to nested properties
A property flag may name a nested property, and the edit lands on that leaf with its siblings left standing, where a plain flag replaces the whole value. fjall modify pattern --name WebApp --storage.media.versioned true turns versioning on for a Payload site’s media bucket — the cure a posture finding on that bucket names — without restating the rest of its storage block. An unknown segment at any level, a segment that lands on a non-object property, and a path passed beside its own prefix are refused before anything is written. A pattern’s edit is judged against its own type’s configuration, so the same flag on a static site is refused as not a pattern property, and a pattern’s type cannot be changed in place. See Nested properties.fjall list --json names the workload a pattern builds for
A pattern entry carries resourceName, the name the pattern was added with and the prefix on every resource it builds (web-app-media), beside name, the statement’s own construct id (WebAppPayload), and variant, the pattern’s own type (payload, nextjs or staticsite). Match a CloudFormation resource back to its statement by resourceName, and read variant to know which pattern’s configuration an edit is judged against. See JSON output.The CLI requires Node 22.3
fjall and every @fjall/* package except the infrastructure constructs now declare engines.node >=22.3.0, up from >=22.0.0. The CLI has read its Node built-ins through process.getBuiltinModule since fjall 25, and that API exists from Node 22.3, so on 22.0 to 22.2 the first credential read failed at run time; npm install -g fjall on those versions now refuses at install time instead. Node 22.3 or later, including the 22 and 24 LTS lines, is unaffected.fjall create says what the first posture scan will list for the chosen tier
fjall create says what the first posture scan will list
The review step of the application flow — the tier picker — now prints Posture findings this tier produces under the settings list: one line per finding the tier’s backup defaults produce, named by the rule the posture scan lists it under, with the setting that causes it and the expectation in the scan’s own words. The lead says where the scan lists them, which depends on your organisation’s posture setting: a development or staging account files them as expected at this stage and any other account as open, or every account as open when the organisation’s posture is strict. The same note the fjall.io create-app wizard shows, so the two cannot disagree.The note informs the choice and gates nothing. Tiers that turn a default on print fewer lines or none. The posture setting is read live at the review step. A logged-out run gets a sentence instead of lines, and a control plane that predates the posture field, or cannot be reached, is reported as “could not be read”, never as lenient. The pattern flow prints no note: a pattern deploys Lambda and CloudFront, which the application tiers’ findings do not describe. See What the review step says about posture.A migration hook no longer fails a deploy on ECS’s first look
The migration that runs before an ECS service scales up is launched by a deployment lifecycle hook, and ECS can take a few seconds to show a task it has just started. The hook read that first empty answer as the task being gone and failed the deployment, while the migration ran to completion against a release that had already been rolled back. The hook now keeps polling until the task is visible. A task that disappears after it has been seen, or one that never appears, still fails the deployment loudly and names ECS’s reason. Your next deploy of an ECS service with a migration picks the change up.A CDN with logging on refuses an opt-in region at synth; cleaning up a failed stack switches deletion protection off first; SNS topics follow the backup tier
Logging into an opt-in region is refused before anything deploys
Through fjall 33, aCdn (or a CloudFrontDistribution) with standard logging on synthesised cleanly in an opt-in region — af-south-1, ap-east-1, ap-south-2, eu-south-2, il-central-1, me-central-1 and the others — and CloudFormation then rejected the distribution at deploy, after the rest of the stack had been created. CloudFront cannot deliver standard logs to a bucket in an opt-in region. The enterprise backup tier turns CDN logging on by default, so an enterprise app in one of those regions hit this on its first deploy.fjall 34 refuses at synth, naming the region and both cures:enableLogging: falseon that CDN. The tier default is overridden for that one construct; nothing else on the tier changes.- A
logBucketin a region that is enabled by default —Bucket.fromBucketAttributes(…, { region }), or a bucket in a stack pinned to that region — so logging stays on and delivers cross-region.fromBucketNameandfromBucketArninherit the distribution stack’s region, so in an opt-in stack they are refused too.
enableLogging: true, or a logBucket without an explicit enableLogging: false. A region fjall’s metadata does not list is refused too, naming the metadata as the cure. Every other deployment is unaffected.Cleaning up a failed stack no longer orphans a protected database
When a deploy left a stack in a cleanup-safe failed state, the cascade destroy emptied its buckets and deleted the stack with every deletion guard still on: RDS and DynamoDB deletion protection, and the load balancer’s. The delete failed on the guarded resource, and the retry retained it — a database, table or load balancer left outside CloudFormation with its guard on, beside buckets already emptied.From fjall 34 the cleanup runs the same pre-flight asfjall destroy: every guard is switched off and read back off before the first mutating call, or the cleanup halts with the cure and empties nothing. The pre-flight now records the RDS deletion-protection transition it observed rather than assuming one, so a halt says what actually happened. cleanupFailedStack in @fjall/deploy-core takes the AwsProvider — a signature change only if you called it directly.SNS topics follow the backup tier, and one seam mints the stack key
SNSTopic gains encryption: "SSE_KMS" | "NONE", the same vocabulary as SQSQueue. Left unset, the app’s backup tier decides: SSE_KMS at enterprise, NONE below. "NONE" at enterprise is the auditable opt-out, "SSE_KMS" below enterprise puts one topic on the stack’s shared key, and masterKey still beats both. No existing topic’s template changes.Every tier-keyed encryption site — DynamoDB, ECS, Lambda, SNS, the ClickHouse maintenance log groups — now goes through one seam: an opt-out at enterprise mints no key, and an opt-in below enterprise mints the shared stack key, never a per-resource one.Smaller changes
grantExecuteCommandon an ECS compute is resource-scoped and, atenterprise, includes thekms:GenerateDataKeythe encrypted session channel needs, so an operator granted through it can open an ECS Exec session.- The stack’s access-log bucket carries the
fjall:s3:versioning-exemptmarker with the valueaccess-logs, so a posture scan reportsS3_VERSIONING_DISABLEDon it as mitigated, with the reason, rather than open. - The dev-OU RDS cost guard in the governance presets evaluates an instance class only when the request carries one, and admits
db.serverless, so switching deletion protection off an Aurora Serverless cluster in a hardened dev OU is no longer denied by the cost guard.
SSH access is three decisions, not one boolean
SSH access is three decisions, not one boolean
enableSSH: true on an Ec2Instance, and ssh: {} on an EC2 compute, did four things at once: created a key pair, opened TCP 22 to 0.0.0.0/0, forced the group into public subnets and gave every instance a public IP. The keyName and allowedCidrs fields on ssh were declared and never read, so ssh: { allowedCidrs: ["203.0.113.4/32"] } narrowed nothing.Both are replaced by sshAccess, which makes each decision its own field:Omit
sshAccess for Session Manager-only access, which is still the default. "0.0.0.0/0" still deploys, with a synth warning that names the instance.What to change
A file still carrying
enableSSH: true or ssh: {} fails at synth with the translation, so an infrastructure.ts that quietly deployed a world-open rule cannot do so again without saying so. When fjall add regenerates the file, a bare ssh: {} is rewritten as the sshAccess it deployed, and an ssh carrying keyName or allowedCidrs is left in place for you to translate. fjall modify --sshAccess replaces an ssh prop outright.Two new refusals close gaps the boolean never had to face. A keyName equal to <serviceName>KeyPair is rejected, because naming the key pair the construct creates would switch it to an imported reference and delete the managed key pair with its stored private key. A list token as allowedIpCidrs (valueAsList, Fn.split over a parameter) is rejected, even spread into a literal list, because each range becomes one ingress rule and the list must be literal at synth; a token per element still passes through, and Fn.split over a literal string is already a list.Changing keyName or public on a deployed group changes the launch template, so the next deploy runs an instance refresh. Changing allowedIpCidrs edits the security group’s rules in place.Requires fjall 34. See EC2 Instance, Compute Factory and fjall add.The backup tier owns its defaults; destroy switches deletion protection off first
Your backup tier now decides more than backups
App.getApp(name, { backup: { tier } }) already selected the AWS Backup plan and, since fjall 30, S3 versioning. It now owns every construct default that is really a durability or compliance posture, so the highest tier deploys with nothing left to switch on, and a lower tier is a deliberate configuration rather than a gap the compliance scan reports.An explicit value on the resource still wins.
backupRetention: 7, deletionProtection: false, encryption: "AWS_MANAGED", containerInsights: false, tracing: false, enableLogging: false and databaseInsights: { mode: "standard" } each override the tier for that one resource, and the override is what the compliance scan then expects to see.What changes on an existing application at its next deploy
- Scaffolded Resilient applications. Database Insights moves from advanced to standard mode, because the scaffold no longer writes
mode: "advanced"and the Resilient tier does not default it. AdddatabaseInsights: { mode: "advanced" }to the database to keep it. The KMS key the scaffold set for insights storage is untouched. - Every scaffolded application.
backupRetentionleaves the scaffold and the tier’s value applies. AbackupRetentionyou wrote yourself stays as written. - Payload applications. The database’s default retention rises from 7 days to the tier’s value, 14 days without a tier. Set
backupRetention: 7to keep it. - Resilient and Enterprise applications with DynamoDB tables. The tables gain deletion protection in place.
- Enterprise applications. Each stack gains one KMS key and one access-log bucket, and the application gains Container Insights, active tracing and advanced Database Insights. A KMS key bills about $1 a month; Container Insights, X-Ray and advanced Database Insights bill per metric, per trace and per instance-hour.
Destroy switches deletion protection off first
fjall destroy, fjall ci run destroy, a destroy started from fjall.io and an organisation cascade now list every RDS instance and cluster, DynamoDB table and load balancer in each stack, switch deletion protection off on the ones that have it, say so, and only then delete the stack. If a switch fails, for example on a denied rds:ModifyDBInstance, dynamodb:UpdateTable or elasticloadbalancing:ModifyLoadBalancerAttributes, the destroy stops before deleting anything and prints the AWS command to run by hand. The administrator deploy role fjall mints carries those permissions; a role you scoped by hand may need them added.Scaffold and codemod
containerInsights on an ECS compute accepts false as well as true, so fjall modify can record the opt-out at Enterprise. Tier presets no longer write backupRetention or databaseInsights.mode; both are the tier’s to set.Requires fjall 33. See Database Factory, Resilient Pattern and Enterprise Pattern.Asia Pacific target names, SSH is no longer part of an instance size
Asia Pacific, Africa and southern Europe target names changed
A deployment target is named<accountname>-<regionabbrev>, and the region part is the only thing that says where a deploy lands. Abbreviating the compass word to a single letter gave ap-southeast-1 and ap-south-1 the same code, aps1, and likewise aps2 for ap-southeast-2 and ap-south-2.Compound and ambiguous directions now get two letters:The same applies to the rest of
ap-southeast-*, ap-northeast-*, ap-south-*, plus af-south-1, eu-south-1, eu-south-2 and me-south-1. Every other region keeps the name it had, so production-use2, staging-euw1 and development-euc1 are untouched.The old codes are retired rather than re-pointed. Naming one is refused with the replacement to use, so a command written against aps1 stops rather than resolving to a region you did not name. Run fjall target list for the current names, then fjall target set <name>.Requires fjall 32. See fjall target.SSH is no longer part of choosing an instance size
The “Standard instance” preset in thefjall add compute wizard enabled SSH. Because presets skip the advanced step, nothing asked first, so picking a size also put the instance in a public subnet with a public IP and opened port 22 to 0.0.0.0/0.Choosing a size now decides only the size. Set sshAccess in infrastructure.ts yourself, or pass --sshAccess to fjall add compute (fjall 34 replaced ssh with it; see the 5 September entry). Existing applications are unchanged: this is the wizard’s default for new compute, not a change to what your file already says.Requires fjall 32. See EC2 Instance.The VPC peering companion command is runnable
When the two halves of a peering pair live in different repositories, the wizard prints the command to run in the other one. That line named a--for flag that does not exist and carried none of the properties the peer half needs. It is now rendered from the same properties the single-repository path writes, so it can be pasted as printed.Requires fjall 32. See VPC peering.fjall target set <name> honours the name at a terminal
Naming a target opened the account and region pickers anyway and ignored the argument, so setting a named target took --non-interactive. It now sets what you named, at a terminal or not. fjall target set with no name still opens the pickers.Requires fjall 32. See fjall target.Deploy-role permissions boundary, object versioning on resilient buckets
Deploy roles are capped by a permissions boundary
Every deploy role Fjall creates in a connected AWS account now carries aFjallDeployBoundary permissions boundary, rendered by default by both the CloudFormation quick-create template and its CDK equivalent. The boundary caps what a mistaken or compromised deploy can reach, and it protects itself at every tier: a deploy cannot widen or delete the policy that constrains it.The policy is an account-global singleton, adopted by name if it already exists and created if it does not, through a custom resource rather than a stack-owned managed policy. Connecting a second account, or re-connecting an existing one, no longer risks an EntityAlreadyExists refusal or a connector stack stuck in DELETE_FAILED. fjall org destroy removes the policy only when the last stack referencing it goes.Requires fjall 31. See Configure deployment credentials.Resilient and enterprise buckets version their objects
S3 buckets provisioned at theresilient or enterprise backup tier now enable object versioning by default, so an overwrite or an accidental delete stays recoverable. Buckets at other tiers are unchanged, and an explicit versioned setting always wins.Requires fjall 30. See S3 Bucket.Destruction consent covers five more stateful types
The destruction gate covers log groups, secrets, KMS keys, hosted zones and user pools
The data-loss oracle already recognised databases, buckets, volumes and file systems. It did not recognise five types whose destruction is equally irreversible, so a deploy whose plan deleted or replaced one of them went through ungated:All five are now stateful to the oracle, so deleting or replacing one requires the same per-resource consent as a database.Requires fjall 13. See Destructive changes.
ECS on EC2 rejects impossible container placements at synth
Containers that cannot fit their instance type fail at synth
On the EC2 launch type, ECS places a task only if the sum of its containers’ hard memory limits fits the memory an instance actually registers with the cluster. That is always strictly below the instance type’s nominal figure, because the ECS agent and OS reserve a slice first. A t4g.medium registers 3835 of its nominal 4096 MiB. A task sized against the nominal number can never be placed, on any instance count.The constructs now reject that shape during synth, with the numbers, instead of leaving you to read a placement failure at deploy time.The ECS on EC2 default instance type is now t4g.small
The previous default, t4g.micro, was structurally unable to run an all-defaults service: roughly 896 MiB usable against a 1024 MiB default container limit. The default is now t4g.small, where the default container fits both on its own and alongside the schema-gate sidecar.Requires fjall 12. See ECS Cluster.Scaffolds no longer overwrite your edits
Scaffold commands refuse to overwrite files you have edited
fjall create account, fjall create organisation and fjall domain import used to render their scaffold straight over whatever was on disk. Re-running create account after a failed install step silently reverted a hand-edited infrastructure.ts to the template; a domain re-import clobbered the previous one without comment.All three now guard every scaffold write and stop rather than discard your work.Requires fjall 8. See fjall create.Domains are typed infrastructure, with classification and ceremonies
Domains are declared in infrastructure.ts
A domain is now declared with the typed Domain construct in fjall/domains/<zone>/infrastructure.ts, a discriminated union on registrar. The older directive-driven pipeline is gone, and BIND survives as an import and export format rather than a source of truth.New domain verbs
Zone safety
Every live record classifies asdeclared, satellite (owned by another live stack), residue (provably orphaned) or unknown. The classifier fails closed: an evidence gap yields unknown, never residue, and unknown records are never remediable.Destructive domain paths run the same consent ceremony as deploys. Consent is per row and by exact name, and a withheld ceremony exits 4: success-shaped, with nothing mutated.Requires fjall 3. See fjall domain and Custom domains.Static sites, and recovering a wedged deploy slot from the CLI
Static sites
fjall create app --pattern staticsite deploys a pre-built static site from a private S3 bucket behind CloudFront with Origin Access Control, at roughly $0 per month. Single-page and multi-page routing, form handling, and a custom domain are all flags on the create.See Static site pattern.Recover a wedged deploy slot without the dashboard
fjall deployments cancel <id>requests co-operative cancellation of the deployment holding the slot. If the occupier is a dead remote source, the response distinguishes “still progressing, wait” from “demonstrably silent” and prints the exact force-release command.fjall deployments force-release <id> --confirm <id>clears the slot. It is owner and admin gated, audited server-side, and requires a signed-in user credential, so agents and CI tokens route it through a human.
Drift repair, destructive-change consent, and remote deploy control
Destructive changes require explicit consent
A deploy whose plan would delete or replace a stateful resource now stops with exit code4 until every such resource is consented individually, by exact physical name and verb. No flag bypasses the gate: --auto-approve, --skip-confirmation and --force never satisfy it.Interactive deploys walk a per-resource ceremony. CI passes --remediate <physicalName>=<verb>, optionally pinned to the reviewed plan with --destruction-consent-digest, and a consent minted against a plan that has since moved is refused. A withheld deploy mutates nothing and frees the organisation’s deploy slot immediately.See Destructive changes.fjall drift detects and repairs out-of-band deletions
Deleting a stack resource in the AWS console used to wedge deploys indefinitely, because CloudFormation keeps failing NotFound.fjall drift detectprobes each stack’s resources against their owning services and reports confirmed, unconfirmed and recovered verdicts.fjall drift repairruns the consent-gated repair (forensics capture, retain flip, surgical template removal), journalled and resumable withfjall drift resume.- A failed deploy whose root cause is a confirmed out-of-band deletion now names the resources and prints the exact repair command.
Deploys are observable and cancellable from fjall.io
Tracked deploys send a liveness heartbeat, consult the deployment lease before every mutating CloudFormation boundary, and honour a cancel initiated from the dashboard across deploy, destroy, rollout and rollback. A remote cancel renders a cancelled outcome with exit code2, never a false success, and lands before the next stack mutation with completed stacks left standing.CI should treat exit code
2 as “stopped without failure” rather than as a deploy failure. It covers a remote cancel and a plan halted at the approval gate.Releases, rollback, in-place rollouts, and the CI command group
Releases and rollback
fjall releases lists the releases minted from your deploys, and rollback is release-addressed with per-service granularity. Application ECR repositories moved from count-based lifecycle retention to release-aware retention, so a rollback target is not garbage-collected out from under you.See fjall releases.fjall rollout restarts services in place
fjall rollout <app> restarts an application’s services without a full deploy: the lightweight way to apply a secret or connection change. fjall secrets set and delete now detect consuming services and offer the roll.See fjall rollout.The CI command group
fjall ci covers setup, token, status, verify and run. Both CI plugins, the GitHub Action and the Buildkite plugin, are thin shims over fjall ci run, so all three surfaces share one code path and behave identically. CI deploy tokens are capped at 90-day expiry at mint time.See fjall ci and CI/CD.Upgrading
@fjall/components-infrastructure in your application to match, and redeploy. If the two drift apart in the unsafe direction, the deploy refuses before it changes anything in AWS and tells you which version to install. See Engine compatibility.