Skip to main content
What shipped, newest first, across the 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 first fjall 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-exports for <zone>-hosted-zone-id, and if an application exports it, bind that application’s zone with domainConfig.managedDomain: { zoneName, hostedZoneId } in place of HostedZoneFactory.import, and deploy the application before you plan the domain;
  • remove from infrastructure.ts every record another stack owns, and treat every record fjall domain records list marks unknown as 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.ts leaves out.
Adopt a live Route 53 zone gives the commands for each step.
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/generator plan 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 databaseName as a string literal (databaseName: "primary");
  • change databases with fjall modify or fjall add <type>, which edit the file in place;
  • after any command that rewrote infrastructure.ts, compare every databaseName with the deployed one before you run fjall deploy, and restore any that changed.
Updated 15 October 2026: fjall 39.0.8 rewrites 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:ListGrants and kms: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.
Fjall reads them so that its orphan checks can tell a key or snapshot nothing uses from one that another account uses, or that its policy forbids deleting, before they recommend a deletion. Fjall keeps what the reads decide: which accounts a key admits, whether its policy refuses its deletion, when KMS last recorded it in use, and which accounts a snapshot is shared with. It never stores the key policy itself.The scan role you connected with already allows all four calls: its 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 a persistentDataVolume, 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 StopTask and 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.
The handler logs 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, no DetachVolume call was made, and every write the host had synced before the refresh survived it.
Do not redeploy 39.0.1 over 39.0.2. The 39.0.1 template has no dependency from the Auto Scaling group to its handlers. So the refresh that such a deploy starts can run while the handler’s permissions are being replaced. In the same test, a drain handed over like that could not read its tasks. It terminated the instance anyway with drain_unproven, and ClickHouse was down for about twelve minutes instead of the usual two or three. To roll back from 39.0.2, pin 39.0.2 or a later 39.x.

ClickHouse formats a data volume only when it is blank

At boot, ClickHouse’s user data mounts the data volume when blkid -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 starting fjall-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

Bump your application’s constructs to match, then redeploy:
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 stamped fjall: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.
Budget the seven-year record as warm storage. The 90-day transition is what the plan asks for; what it buys depends on the type. AWS Backup honours it for DynamoDB tables where the account has advanced DynamoDB features enabled. It does not honour one for S3 buckets, RDS instances or Aurora clusters at all — the expression is ignored, silently, and those recovery points stay warm for the full seven years. EBS volumes can go cold only through Snapshot Archive, which this release does not emit.Two warnings say when the configuration has run out of road. 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.
If you re-owned the compliance vault’s encryption key — you passed your own key rather than letting Fjall create one — synth now warns and names the key-policy statement to add. Cross-account copies into that vault were already failing at the destination, where nothing in the source account surfaces it. The warning is the first place that says so.
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 no backupExemption — 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.
Changes made while the setting was off are not captured retroactively. A bucket’s point-in-time window begins tracking from the next backup job after this deploy, and the window over the period before it was never real. If you have been relying on S3 point-in-time restore on a Fjall-managed bucket, treat any point before this deploy as unavailable and take a snapshot restore instead.
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 DescribeServices spanning clusters is a call ECS rejects whole. Describes are now issued per cluster in batches of ten, and services ECS reports under failures are 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 aws whatever 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.
A ClickHouse Database stack loses three cluster outputs at its next deploy. The diff is outputs only — but CloudFormation refuses to delete an export another stack imports, so if anything Fn::ImportValues one of them the deploy fails and rolls back. Nothing fjall generates imports them; a hand-written infrastructure.ts may. Check once per name before upgrading:
Export ... is not imported by any stack is the answer you want. If a stack does import one, drop the import and deploy that stack first — an export cannot be removed and re-added in the same operation.
  • fjall services <app> --json no longer carries a top-level clusterArn; each entry carries its own clusterArn and clusterName.
  • 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 deploy still deploys, but warns that artefacts are recorded without task-definition ARNs.
  • A narrowed SSO permission set or SCP needs cloudformation:DescribeStacks on the app’s Compute stack (and its Network stack for the VPC CIDR capture). Do not drop cloudformation:ListExports — the ECS resolver no longer calls it, but the ECR repository-name lookup that fjall build and fjall deploy run 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 rollout confirms 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: boolean becomes verdict: "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.
See fjall services and fjall rollout.

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.
That redeploy leaves the old implicit key behind, retained, aliasless and still billed. Messages already on the queue stay readable, so wait out the queue’s retention period (14 days at most), confirm it has drained, then schedule the old key for deletion by hand. Fjall cannot find it for you — being unfindable is the defect this release closes, and it cannot be closed retroactively for keys that already exist.

Every child that may run customer code declares an environment policy

The CDK CLI executes your own infrastructure.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 bare parseInt. 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:
  • --name reaches AWS Backup on every lane, becoming the restored instance’s DBInstanceIdentifier. The interactive picker seeds its prompt with it.
  • --name is refused for s3, ebs and ec2. Those are named by AWS Backup from the recovery point and have nowhere to put one.
  • --name is 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 own Default, and the failure names the vault that was actually read.
A scripted fjall restore rds that failed in fjall 38 with a complaint about missing restore metadata now succeeds.
IAM: every restore now reads backup:GetRecoveryPointRestoreMetadata. A role scoped to the policy published for fjall 38 needs that action added, or the restore exits with the permissions hint.
See fjall restore.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 as destructionConsentReport — 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.
A human withhold prints the same ticket and verdict lines as before, now written by the CLI rather than the engine. Agent output gains 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

Bump your application’s constructs to match, then redeploy:
CI pins move together — 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/util no longer exports TOKEN_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 deploi used to fill a captured variable with the whole help screen.
  • Redirecting one stream no longer loses the other’s colour. fjall deploy api | tee out keeps coloured progress on the terminal, and fjall deploy api 2>deploy.log keeps the log free of escape sequences.
See CI/CD and fjall secrets.

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 a CloudFrontDistribution, 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:
The replacement is one prop. On the Cdn pattern, and so on CdnFactory:
Absent, the backup tier decides exactly as before: 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 the Logging 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.
The stack access-log bucket is now BucketOwnerEnforced. A bucket legacy logging wrote to (an enterprise-tier CDN, or any CDN with enableLogging: true on the stack bucket, deployed on fjall 33 through 37) carries the FULL_CONTROL ACL grant CloudFront’s awslogsdelivery canonical user added and never removes. S3 refuses the ownership flip and the deploy fails on the bucket with InvalidBucketAclWithObjectOwnership.
Cure once, then redeploy. The objects already in the bucket are unaffected:
A logging bucket CDK created for you is left in your account. A hand-written 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 an SQSQueue 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::QueuePolicy in 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.arnSizeEstimate when the refusal names references it could not size.
Every producer counts against the same budget, so a wide SNS fan-in reaches it too: 19 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

A Domain 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>Value by 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 verify no longer looks up nameservers for an external-records domain, and --show-delegation refuses 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:
See Domain and fjall domain.

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 the DisasterRecovery 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.
The target is resolved and its credentials acquired before the subcommand runs, ahead of the interactive picker as well as the non-interactive path. An unknown target fails and names the targets available rather than falling through to the active one. Because the flag sits on the parent too, it can be given before the subcommand and it reaches the picker. There is deliberately no --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.
The verb removes the registration and nothing else. The retyped application name is the consent, checked before the authentication round trip alongside the --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 with ticket 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, --app on fjall aws exec scopes the deploy session’s permissions to an application. It does not choose the account, and never did. Use -t, --target for 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>UsEast1Certificates or <App>UsEast1CdnLogging stack 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 the aws command 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 --force skips that guard.
  • --output on fjall accounts is refused by the parser for anything but human or json. An unlisted value used to fall through to the human table. fjall apps detect already refused one.
  • BackupVaultProps.encryptionKey is typed IKey, so passing the CustomerManagedKey wrapper no longer compiles. Pass backupKey.key. Supplying a key now also creates the alias/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 app adds 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/generator embedders: ECSGeneratorSchema, LambdaGeneratorSchema and EC2GeneratorSchema are removed in favour of ComputeGeneratorSchema.
  • The posture resource-type vocabulary and the codemod snapshot readers ship in @fjall/util and @fjall/generator rather than being re-declared beside them. Additive, with no template, construct id or schema change.
See fjall add, fjall destroy, fjall unlock and KMS Key.
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 lists S3_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, a Cdn (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: false on that CDN. The tier default is overridden for that one construct; nothing else on the tier changes.
  • A logBucket in 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. fromBucketName and fromBucketArn inherit the distribution stack’s region, so in an opt-in stack they are refused too.
Logging counts as on exactly when CDK would emit a Logging block: 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 as fjall 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

  • grantExecuteCommand on an ECS compute is resource-scoped and, at enterprise, includes the kms:GenerateDataKey the 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-exempt marker with the value access-logs, so a posture scan reports S3_VERSIONING_DISABLED on 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.
Requires fjall 34. See CDN Factory, SNS Topic and IAM Role.
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. Add databaseInsights: { mode: "advanced" } to the database to keep it. The KMS key the scaffold set for insights storage is untouched.
  • Every scaffolded application. backupRetention leaves the scaffold and the tier’s value applies. A backupRetention you 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: 7 to 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 the fjall 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 a FjallDeployBoundary 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 the resilient 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 as declared, 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.
See fjall deployments.
Drift repair, destructive-change consent, and remote deploy control
A deploy whose plan would delete or replace a stateful resource now stops with exit code 4 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 detect probes each stack’s resources against their owning services and reports confirmed, unconfirmed and recovered verdicts.
  • fjall drift repair runs the consent-gated repair (forensics capture, retain flip, surgical template removal), journalled and resumable with fjall 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.
See fjall drift.

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 code 2, 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

Then bump @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.