> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK release notes

> Release notes for the formae Plugin SDK packages: pkg/plugin, the conformance-test framework, and the plugin template.

This page tracks releases of the formae Plugin SDK packages:

* **pkg/plugin** - Core SDK for building plugins
* **pkg/model** - Shared types a plugin declares against, such as discovery filters
* **pkg/plugin-conformance-tests** - Conformance test framework
* **pkg/credential** - Wire contract and entry point for oidc-credential broker plugins
* **[formae-plugin-template](https://github.com/platform-engineering-labs/formae-plugin-template)** - Scaffold for new plugins

***

## September 2026

### model v0.1.28

*Released September 2026. Requires formae ≥0.89.0.*

**Feature**: discovery filters can be evaluated from your own tests

`MatchFilter` now carries the evaluation that decides whether a resource is excluded, as `Excludes(properties)`. Previously the type was exported but the evaluation lived inside the agent, so a plugin could announce discovery filters it had no way to exercise. That mattered because the failure is silent in the wrong direction: a filter whose JSONPath does not match the shape your plugin actually serializes excludes nothing, which looks exactly like the resource not being there.

Pair it with `FiltersForType` to run the same filters the agent would, against a payload from your own Read:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
filters := model.FiltersForType((&Plugin{}).DiscoveryFilters(), "AWS::IAM::Role")
for i := range filters {
    if filters[i].Excludes(props) {
        // discovery would skip this resource
    }
}
```

Build the payload from what your plugin emits rather than by hand. A hand-written fixture can encode the same wrong assumption the filter does, and then both agree and the test passes while discovery excludes nothing.

## August 2026

### formae schema 0.89.0

*Released August 2026. Requires formae ≥0.89.0.*

**Feature**: first-class secret base types

The schema adds a small set of base types for modelling a secret as a managed resource, so a secret's value can be referenced the same way everywhere instead of each plugin inventing its own accessor. A secret resource extends `formae.Secret` and names its value property. Pick the shape by the resolvable it uses: `formae.ScalarSecretResolvable` for a single-value secret (an AWS `SecretString`, an Azure Key Vault `value`), or `formae.MapSecretResolvable` for a keyed secret (a Kubernetes `data` map, a Vault KV path), whose `SecretMapAccessor.at("key")` returns a resolvable to one entry. Type the value field `formae.SecretValue` to mark it opaque so the agent hashes it at rest. Consumers reference the value through the `secretValue` accessor, and `.json("path")` navigates into a resolved JSON string. The shape is enforced at eval time: `.at()` on a scalar, or a bare `secretValue` on a map, is a Pkl type error rather than a runtime surprise.

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
// Scalar (AWS): res.secretValue is the value
open class SecretResolvable extends formae.ScalarSecretResolvable {
  hidden valueProperty = "SecretString"
}

// Map (Kubernetes): navigate with .at("key")
open class SecretResolvable extends formae.MapSecretResolvable {
  hidden valueProperty = "data"
}
```

**Feature**: `preserveEmptyValues` field hint

`FieldHint` gains `preserveEmptyValues: Boolean` (default `false`). It marks a top-level field whose subtree carries meaningful empty collections: the agent then preserves `{}` and `[]` inside that field byte for byte through diffing, patch generation, and every plugin-bound payload, instead of stripping them as rendering noise. Use it for opaque document fields the provider owns the grammar of, where an empty member is itself the declaration, and pair it with `updateMethod = "Atomic"` so the document also diffs as one whole value:

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@FieldHint { updateMethod = "Atomic"; preserveEmptyValues = true }
spec: Any?
```

Two version couplings, both enforced or they bite silently: the hint requires formae ≥0.89.0 (an older agent applies the Atomic half and keeps stripping empties, reproducing the bug the hint exists to fix, so bump `minFormaeVersion` when adopting it), and the plugin must build against `pkg/model` ≥v0.1.27 (the extraction pipeline drops the hint at JSON unmarshal on older versions with no error). The hint is top-level only: a dotted subresource hint key carrying it is ignored. `pkg/model.FieldHint` gains the matching `PreserveEmptyValues bool` field.

**Change**: the `RedactSensitive` request flag is removed

The discovery-only `RedactSensitive` flag is gone. Opaque values are now hashed at rest by the agent regardless of the plugin, so the flag that told a plugin to skip enrichment during discovery no longer serves a purpose. A plugin that referenced it must drop the reference. The change is wire-compatible: plugin-agent messages serialize as map-keyed MessagePack, so removing the field forces no recompile of other plugins.

### credential v0.1.0

*Released August 2026. Requires formae ≥0.89.0.*

**New package**: `pkg/credential`, for building an oidc-credential plugin

A second kind of plugin now has an SDK. Where `pkg/plugin` is for resource plugins, `pkg/credential` carries the wire contract for an **oidc-credential plugin**: a broker that mints short-lived OIDC identity tokens on the agent's behalf, rather than managing resources.

Implement `OidcCredentialPlugin` and hand it to `credential.Run`, which mirrors the resource-plugin entry point:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
import "github.com/platform-engineering-labs/formae/pkg/credential"

func main() {
    if err := credential.Run(&MyBroker{}); err != nil {
        log.Fatal(err)
    }
}
```

The package also carries `OidcIdentityTokenRequest` and `OidcIdentityTokenResult`, the `IdentityTokenResponse` envelope with its fail-closed error mapping, an optional `Configurable` interface for brokers that take configuration before serving, and manifest reading. A broker declares `Type = "oidc-credential"` in its `formae-plugin.pkl`; `Validate` rejects a manifest missing what the type requires, and the announcement it sends on startup mirrors `pkg/plugin.PluginAnnouncement`.

```
github.com/platform-engineering-labs/formae/pkg/credential v0.1.0
```

This is the first tagged release of the module. Plugins that were consuming it at a pseudo-version should re-pin to `v0.1.0`.

### plugin v0.4.2

*Released August 2026. Requires formae ≥0.89.0.*

**Change**: schema verification rejects a `@FieldHint` that can never take effect

Verification now fails when a `@FieldHint` sits on a class that does not extend `formae.Resource` or `formae.SubResource`, directly or transitively. Extraction only walks nested classes that satisfy that test, so such a hint was invisible to `Schema.Hints`: the annotation had no effect at all, and a class reached as a nested property of a resource also missed the `SubResource` render path, including the absent, explicit-null and explicit-empty handling for nullable collections. The failure is new, not the defect; a schema that verified before and fails now was already carrying a hint that did nothing. Fix it by extending the right base class rather than by removing the hint.

**Fix**: a failing status call no longer terminates silently

When `Status` returned a Go error, the operator ended the operation without sending a final progress result. The agent had nothing to go on, waited out its watchdog window, and reported the plugin as unresponsive, discarding the real error. Every terminating path now delivers a final progress result carrying the actual failure, and a status call that failed on a throttle is retried as another status check rather than reissuing the original operation.

### conformance-tests v0.2.7

*Released August 2026. Requires formae ≥0.89.0.*

**Feature**: an omit-and-observe sweep for `hasProviderDefault`

The CRUD suite can now record what a provider actually does with every field a schema annotates `hasProviderDefault`. It needs no new test: the fixtures already declare a subset of the schema, so every annotated field a fixture omits is created omitted, and the suite already reads the resource back twice. The sweep captures those two reads per annotated field instead of discarding them.

Set `FORMAE_TEST_PROVIDER_DEFAULT_OBSERVATIONS` to a file path to turn it on. Unset, the CRUD suite behaves exactly as it did. `FORMAE_TEST_SETTLE_SECONDS` widens the gap between the create echo and the post-sync read for a provider that populates asynchronously, capped at 300 seconds so one case cannot stall a shared matrix.

Read the artifact knowing what it can and cannot decide. A field the provider populates when omitted has an empirically justified annotation, and a value that moves between the two reads without formae writing it names a co-actor. A field that never appears decides nothing on its own, because the co-actor that would populate it is absent from an isolated fixture by construction, so an unexercised annotation is a documentation question rather than grounds for removal. The two reads are seconds apart, which catches asynchronous population but not a value the provider moves on a maintenance-window cadence.

**Change**: the CRUD Extract phase asserts a faithful round trip

The Extract phase now extracts the resource, simulates re-applying the extracted forma, and requires the plan to be empty. Previously it performed a real patch-mode apply and asserted only that the apply succeeded and that one resource remained in inventory, so an extract that lost information could mutate the resource and still pass.

Expect this to surface real defects on first run. An extract that omits a property, or emits it in a form that does not compare equal to what the provider reports, now fails with the planned change instead of passing quietly. Simulating rather than applying also means the phase no longer mutates cloud state.

**Feature**: round-trip failures name the property

A failing round trip renders the planned update's patch document, so the failure identifies the offending property directly instead of reporting only that changes were planned.

**Feature**: a failed command reports why it failed

A conformance failure used to say only that the command reached a terminal state:

```
[Create] Apply command should complete successfully: command reached terminal state: Failed
```

That was the whole diagnostic, so finding the cause meant pulling the agent's debug log out of a CI archive and reading it by hand. The harness already had the answer: the status response it fetches carries an error message on every failed resource and target update, and it was discarding everything but the state. Failures now name the resource and quote the reason:

```
command reached terminal state: Failed
  target example-sandbox (create): resolve config: no such secret
  EXAMPLE::User conformance-user (create): 401 Unauthorized
```

Target updates come first, because a target that failed to come up explains why none of the resources on it got started. Updates that succeeded are left out. An update that failed without reporting a reason is still listed, as `(no error reported)`, which separates a resource that failed on its own from one carried down by something else. Reasons recorded before a plugin operation runs, such as a rejected update, are not yet persisted by the agent and show as `(no error reported)`; failures from a plugin operation appear in full.

**Change**: a suite where nothing ran no longer counts as passed

If setup failed before the first phase, for example when a Pkl project could not be resolved, every phase stayed unrun and the summary counted the suite as passing:

```
FORMAE-PLUGIN-EXAMPLE::thing   [-] [-] [-] [-] [-] [-] [-] [-]   0m 00s
1 passed, 0 failed, 0 skipped
```

The Go test still failed, so CI was red, but the summary, which is what most people read, reported success for a run that had tested nothing. A suite where no phase ran now counts as failed. Expect this to flip the summary line on any run that was failing this way, with nothing else about the run having changed. Suites that ran and passed the phases they exercise are unaffected, since leaving later phases unreached is normal.

**Fix**: harness CLI invocations are bounded

Every CLI call the harness makes now runs under a deadline. A stuck apply surfaces as a readable test failure rather than a job that stalls silently for hours with no diagnostics.

**Fix**: version resolution survives a release publish

Resolving the formae binary while a release was publishing to the same channel failed immediately with `no available packages for: formae`, because the channel's package index is briefly inconsistent mid-publish. Resolution now retries across a window that outlasts a publish, on both the pinned-version and the `minFormaeVersion` floor paths. A genuinely empty channel still fails with the underlying error.

## July 2026

### formae schema 0.88.0

*Released July 2026. Requires formae ≥0.88.0.*

**Feature**: `formae.SecretValue` for secret fields

Mark a resource field as a secret by typing it `(String | formae.SecretValue)` (nullable and union variants included). formae derives the field's opaque hint from the type, so the agent stores the value hashed at rest and keeps it out of logs. This is independent of `writeOnly`, which controls read-back and drift; a secret the provider never returns is typically both.

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.FieldHint {}
masterPassword: (String|formae.SecretValue)?
```

### plugin v0.4.1

*Released July 2026. Requires formae ≥0.87.1 for `PriorProperties` to be populated.*

**Feature**: `PriorProperties` on `ReadRequest`

`ReadRequest` now carries an optional `PriorProperties` field holding the caller's last-known model for the resource (the stored row). A plugin's `Read` can consult it to report the projection the caller actually manages. For example, it can embed a child collection only when the prior model declares it inline, so a caller that manages those children as standalone resources is not shown phantom drift or driven to destroy them on a reconcile.

The field is optional and additive: a plugin that ignores it behaves exactly as before, and no migration is required. It is empty when the caller has no prior state, such as a create/status read-back or discovery, so treat empty as "unknown" and fall back to your default behaviour. The agent populates it starting formae 0.87.1; on older agents it is always empty.

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
func (r *Role) Read(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResult, error) {
    // ... read actual cloud state into props ...
    if callerManagesPoliciesInline(req.PriorProperties) {
        props["Policies"] = inlinePolicies
    }
    // ... marshal and return ...
}
```

`PriorProperties` is a `json.RawMessage`; unmarshal it to inspect the caller's model. A read still reports actual cloud state. This hint only disambiguates a resource's boundary where the provider conflates it with its children.

### conformance-tests v0.2.6

*Released July 2026. Requires a formae that hashes opaque secret values at rest.*

**Feature**: Opaque secret values are compared by digest

When a plugin types a value field as opaque (via `formae.SecretValue` in the field's type union, which the extractor turns into `FieldHint.Opaque`), formae hashes that value at rest. So the value a plugin returns from `Read`, and the value `formae extract` emits, is not the authored plaintext but an opaque envelope: `{"$visibility":"Opaque","$hashed":true,"$value":"<sha256>"}`.

The property comparison previously had no awareness of this and fell through to a scalar compare, flagging every opaque secret field as a mismatch (`expected TestPassword123!, got map[$hashed:true $value:… $visibility:Opaque]`). Any resource with an opaque secret field, for example `AWS::RDS::DBInstance.MasterUserPassword`, failed the after-create and after-extract property checks.

`compareProperties` and `compareMap` now recognise the opaque envelope and verify it by recomputing the SHA-256 digest of the expected value and matching it against the stored `$value`. This is a real integrity check that the plugin stored the authored secret, not a blind skip. When the digest is absent, as it can be after extraction, the envelope is accepted as-is, mirroring how resolvables are handled. Neither the plaintext nor the digest is ever written into a failure message. A plugin whose secret field is opaque now passes the after-create and after-extract checks instead of failing on a plaintext-versus-hash comparison.

**Migration from v0.2.5**: Bump the dependency:

```
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.6
```

Then `go mod tidy`. No code changes required.

## June 2026

### plugin v0.4.0

*Released June 2026. Requires formae ≥0.87.0*

**Breaking behaviour.** The `writeOnly` field hint no longer implies "re-send on every update." `writeOnly` now means only that the provider never returns the value on read, so it is stripped from read-back state and never produces drift. The "the provider drops this value unless it is included in every update" semantics that `writeOnly` previously bundled in now live in a separate hint, `requiredOnUpdate`.

**Migration.** Audit every field marked `@formae.FieldHint { writeOnly = true }`. If the provider *requires* the value to be present on every update (typical of rotating secrets and write-only configuration blocks the API replaces wholesale), add `requiredOnUpdate`, or the value silently stops being sent and the next update drops it:

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.FieldHint { writeOnly = true; requiredOnUpdate = true }
clientSecret: String?
```

A field the provider retains across updates without re-sending needs no change: it stays `writeOnly = true` and is no longer needlessly re-sent when an unrelated field on the same resource changes. Run your conformance suite's update phase: a field the provider drops will surface as a post-update diff, which is the signal it needs `requiredOnUpdate`.

**Feature**: `format` on `FieldHint` for serialized-content String fields

A String field can now declare the structured format of its content with `@formae.FieldHint { format = "json" }`. When set, the agent compares the field by its canonical content rather than its raw byte serialization: differences that are purely cosmetic (object key ordering and insignificant whitespace) no longer register as a change, so they neither surface as drift on sync nor produce a no-op update on apply.

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.FieldHint { format = "json" }
dashboard: String
```

The motivating case is a field populated from a pretty-printed source document, for example a Grafana dashboard loaded with `read("dashboard.json")`. The provider stores the JSON in its own canonical (often minified, key-reordered) form, so on the next read the bytes differ from the source even though the document is identical. With `format = "json"` the two are compared as equal JSON documents and the spurious diff disappears.

`format` defaults to `""`, which leaves the field an opaque String compared byte-for-byte (unchanged behaviour). `"json"` is the only format recognised today; an unknown value is rejected when the plugin registers rather than silently ignored, and a JSON document with duplicate object keys is rejected.

### plugin v0.3.0

*Released June 2026. Requires formae ≥0.86.0*

**Breaking behaviour.** `ExtractSchema` now emits every property path, including unannotated ones, with `createOnly` defaulting to `false`. The planner uses this complete schema surface to decide between cascade-replace and cascade-update when a parent resource is being replaced. Any field whose value the provider rejects on update must now be annotated `@formae.FieldHint { createOnly = true }`. Previously the planner could not distinguish "unannotated" from "mutable" and cascade-replaced dependents wholesale; now an unannotated dependent field is treated as mutable and the resolvable re-resolves at apply time, so the dependent absorbs the new parent value through a provider-native Update. Plugins that under-mark immutable fields will see apply-time provider rejections that previously masqueraded as cascade-replaces.

The canonical case this unlocks is an ECS Service consuming a TaskDefinition via the mutable `taskDefinition` field: previously every revision bump tore down and recreated the Service (2–4 minutes of outage); with `taskDefinition` correctly unmarked (i.e. mutable), the Service does a rolling deploy.

**Migration**: audit your schema for fields whose value the provider rejects on update (typical: a resource's name/identifier, region, KMS key ID at create time, networking primitives). Add `@formae.FieldHint { createOnly = true }` to each. Run your conformance suite; the update phase now exercises the planner's mutability decision end-to-end.

**Resolvables follow the same rule.** Cross-resource references (fields whose values are `Resolvable` URIs pointing at other resources) were previously treated as implicitly `createOnly`. They now follow the same rule as scalar fields: a `Resolvable`-typed field is treated as immutable only when annotated `@formae.FieldHint { createOnly = true }`. Plugins where a resolvable-bearing field's value can be safely updated in place by the provider should leave it unannotated; plugins where changing the reference requires a replace should mark it explicitly. The same audit applies: review every `Resolvable`-typed field and add the annotation where the provider rejects in-place updates.

**Feature**: Typed `EdgeKind` on `FieldHint` replaces the `AttachesTo` boolean

`FieldHint` now carries an `edgeKind` union with three values: `default`, `attachesTo`, and the new `runtimeDependency`. The legacy `attachesTo = true` boolean is still accepted and derived to `edgeKind = "attachesTo"` for one release, so existing plugins compiled against `v0.2.2` continue to load unchanged. New plugins should write `edgeKind` directly:

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.FieldHint { edgeKind = "runtimeDependency" }
fileSystemId: (String|formae.Resolvable)?
```

**Feature**: `runtimeDependency` edge kind for instance-scoped destroy ordering

`runtimeDependency` annotates a field whose value points at a specific resource *instance* that must outlive its containment children during destroy. The planner inserts edges from the annotated consumer to each containment child that points at the same producer instance, so the children are destroyed before their parent.

The motivating case is `AWS::EFS::FileSystem` + `AWS::EFS::MountTarget`: an ECS TaskDefinition references a FileSystem via a mutable field, and the FileSystem has MountTargets as containment children. Without this annotation, the engine could delete the FileSystem while MountTargets still held it open, stranding both. Annotating the TaskDefinition's `fileSystemId` field with `edgeKind = "runtimeDependency"` pulls those MountTargets into the destroy ordering ahead of their parent FileSystem.

`attachesTo` (the same semantics as the previous boolean) still flips the destroy edge between two specific resources where the attacher must outlive the host. `runtimeDependency` is the multi-instance, containment-aware variant.

**Feature**: `ParentRef` and `Schema.ParentMappings` describe parent-instance identity

A new `ParentRef` PKL class and `ResourceHint.parentRefs` field let resource types declare which fields identify their parent instance. `Schema.Parent` and `Schema.ParentMappings` are now plumbed from PKL through the descriptor extractor and are available to the planner for instance-discriminated child-points-at lookups during destroy. This is what makes `runtimeDependency` instance-safe rather than type-wide.

**Fix**: `formae plugin init` scaffolder: namespace, substitution, and verify-schema glob

Three rough edges surfaced by the atlas plugin scaffold are fixed:

* Mixed-case namespace input is now normalised to upper-case at input time, with a one-line stderr notice when the input differs from the normalised form. Previously the conversion happened silently at template-generation time and left `config.Namespace` mixed-case internally.
* Five additional template-substitution rules cover placeholders that were leaking through scaffolded output: bare `module example` declarations, PklProject `baseUri` and `packageZipUrl` strings, PKL module-qualifier prefixes (`example.ExampleResource`), the license header year and author (previously hard-coded to 2025), and the new `@example/core/example.pkl` subdir import path that pairs with the template repo's subdir convention.
* The `testutil` `ImportsGenerator.pkl` glob is changed from `import*(@<ns>/**/*.pkl)` to the union pattern already used in the sibling generators under `internal/schema/pkl/generator` and `pkg/plugin/descriptors`. The single-glob form misses top-level files in PKL's glob model, so `make verify-schema` was reporting "Total resource types: 0, PASSED" for plugins that placed resources at `schema/pkl/<resource>.pkl`: a false-success signal that hid real schema problems. The new pattern matches both top-level and nested files.

**Docs**: Package READMEs

`pkg/plugin/README.md` and `pkg/plugin-conformance-tests/README.md` are added so plugin authors who land in those directories directly (via `go doc`, GitHub's package tree, or as a follow-up to `formae plugin init`) get a self-contained orientation. The plugin README covers the `ResourcePlugin` interface, the optional `ObservablePlugin` and `Configurable` interfaces, the async `ProgressResult` flow, the on-disk layout (`formae-plugin.pkl` manifest plus `schema/pkl/PklProject`), the `sdk.RunWithManifest` entry point, and the conventions plugin authors commonly miss (statelessness across operations, mandatory `NativeID` in every `ProgressResult`, JSON-typed properties, no pointer sharing across the actor boundary).

**Migration from v0.2.2**: Bump the dependency and rebuild:

```
github.com/platform-engineering-labs/formae/pkg/plugin v0.3.0
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.5
github.com/platform-engineering-labs/formae/pkg/model v0.1.25
```

Then `go mod tidy` and `make build`. Bump `minFormaeVersion` in `formae-plugin.pkl` to `0.86.0` if you plan to ship any `edgeKind` or `parentRefs` annotations.

***

### conformance-tests v0.2.5

*Released June 2026. Requires plugin ≥0.3.0*

**Feature**: Channel-aware formae resolver with dev fallback

The harness now reads `minFormaeVersion` from your plugin's `formae-plugin.pkl` manifest and uses it as the floor when picking a formae release to install via orbital. It prefers the highest stable release that meets the floor; otherwise it falls back to the highest qualifying release on the dev channel; otherwise it errors with a message naming the required minimum.

This lets a plugin opt into a not-yet-stable formae release by declaring a higher minimum in its manifest. For example, a plugin adopting the new `edgeKind` schema needs formae ≥0.86.0; while 0.86.0 is in its dev-channel window, the harness picks the latest qualifying dev build automatically. Plugins whose declared minimum is satisfied by the latest stable release see no behaviour change.

Each channel uses its own orbital tree under the conformance tempdir, and the dev tree is only initialised when stable cannot satisfy the floor. An explicit `FORMAE_VERSION` (used by nightly and `debug-conformance`) is still honoured exactly, now searched across stable then dev.

**Fix**: Empty-value drift no longer flagged in the reverse comparison direction

The forward direction of `compareMap` and `compareProperties` skips when the expected value is structurally absent (nil, empty array, or empty map). The reverse direction had no equivalent forgiveness: any actual key absent from expected was flagged as drift unless the schema marked the field as `hasProviderDefault`. Cloud providers commonly return `[]` for unset list fields and `{}` for unset map fields, which is semantically equivalent to the key being omitted, but the harness was flagging it.

This forced plugin authors into a bad trade-off for user-canonical fields. Marking `hasProviderDefault` on the schema satisfied the harness, but formae core's strip-provider-default pass then dropped the field symmetrically from apply diffs, silently losing user changes. Setting explicit empty literals in testdata lost test-coverage clarity and bloated fixtures.

The reverse loops now mirror the forward loops' empty-skip. Non-empty unexpected values are still flagged, which is the case that matters for both out-of-band changes and missed `hasProviderDefault` annotations. This unblocks `AWS::ECS::TaskDefinition` (18 `ContainerDefinitions[0].*` fields plus `Tags`), `AWS::ECS::TaskSet.Tags`, and `AWS::SES::EmailIdentity.Tags`, among others.

**Fix**: `FORMAE_VERSION` is now populated when `FORMAE_BINARY` is set externally

`EnsureFormaeBinary` returned early when `FORMAE_BINARY` was already set in the environment, without extracting the version or populating `FORMAE_VERSION`. Downstream, `ResolvePKLDependencies` saw an empty `FORMAE_VERSION`, logged "skipping PKL dependency resolution", and the test's subsequent `pkl eval` failed with `NoSuchFileException` on `testdata/PklProject.deps.json`. That file is gitignored and only materialises once `pkl project resolve` has run, so every nightly that built formae from source regressed in lockstep once the per-plugin `run-conformance-tests.sh` wrapper was retired in favour of letting the SDK manage setup. The externally-set branch now mirrors the orbital path: extract the version, log it, and `t.Setenv FORMAE_VERSION` before returning.

**Migration from v0.2.4**: Bump the dependency:

```
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.5
```

Then `go mod tidy`. No code changes required. If you want to opt your plugin into a dev-channel formae release, raise `minFormaeVersion` in `formae-plugin.pkl`.

***

### Full go.mod bump for this cycle

```
github.com/platform-engineering-labs/formae/pkg/plugin v0.3.0
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.5
github.com/platform-engineering-labs/formae/pkg/model v0.1.25
```

Run `go mod tidy` and `make build` after updating. Bump `minFormaeVersion` in `formae-plugin.pkl` to `0.86.0` if your plugin adopts the new `edgeKind` or `parentRefs` annotations; otherwise the existing `0.84.0` floor still works.

***

## May 2026

### plugin v0.2.2

*Released May 13, 2026. Requires formae ≥0.84.0*

**Feature**: `AttachesTo` field hint for reachability-based destroy ordering

Plugin authors can now annotate a field that `$ref`s another resource with `attachesTo = true` to declare that the resource being defined attaches as a backend to the resource it references, and must outlive it during destroy. The destroy DAG inverts the edge: the referenced resource (the host) is deleted first, and the attaching resource waits.

**When to use it.** Annotate a field with `attachesTo` when the resource being defined provides a live capability (a serving endpoint, a consumer connection) that depends on the continued existence of the resource it references, and when other resources in the stack consume that live capability during their own tear-down. The canonical example is `AWS::ECS::Service.LoadBalancers.targetGroupArn`: the ECS service is the live backend for the listener URL. A Grafana target pointing at that listener URL drives CRUD calls through it during destroy; if the service tears down in parallel with the listener, those calls fail and the agent wedges mid-destroy. With `attachesTo`, the service waits for the listener to be deleted first, keeping the HTTP endpoint reachable throughout the Grafana resources' tear-down.

**DAG semantics.** This is a destroy-only annotation. Create order is unchanged: the normal construction constraint still applies (the attacher is created after its host). On destroy, instead of the default reverse-construction order (consumer before producer), an `attachesTo` edge flips direction: the host is destroyed first, then the attacher.

**PKL syntax:**

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.FieldHint { attachesTo = true }
targetGroupArn: (String|formae.Resolvable)?
```

**Compatibility.** The `AttachesTo` field defaults to `false` on JSON unmarshal of stored resources whose schemas were written before this release. Existing plugins compiled against `pkg/plugin@v0.2.1` or earlier continue to load unchanged. Agents older than `0.84.0` silently ignore the annotation (the field round-trips as `false`).

**Migration from v0.2.1:** Bump the dependency and rebuild:

```
github.com/platform-engineering-labs/formae/pkg/plugin v0.2.2
github.com/platform-engineering-labs/formae/pkg/model v0.1.24
```

Then `go mod tidy` and `make build`. Plugins that do not adopt `attachesTo` need no code changes.

***

### conformance-tests v0.2.4

*Released May 13, 2026. Requires plugin ≥0.2.2*

**Feature**: `--schema-location local` for unpublished plugins

The conformance harness now passes `--schema-location local` to `formae extract` during conformance runs. Plugins whose schema has not yet been published to Hub no longer need a Hub entry to run their conformance suite; the agent resolves the schema from the locally loaded plugin binary instead.

**Feature**: `FORMAE_TEST_TESTDATA_DIR` override

Set `FORMAE_TEST_TESTDATA_DIR` to point at an alternate fixture directory at runtime. This lets plugin authors test against different fixture sets (for example, region-specific resources or staged migration fixtures) without modifying test code or Makefile targets.

**Migration from v0.2.3:** Bump the dependency:

```
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.4
```

Then `go mod tidy`. No code changes required.

***

### Full go.mod bump for this cycle

```
github.com/platform-engineering-labs/formae/pkg/plugin v0.2.2
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.4
github.com/platform-engineering-labs/formae/pkg/model v0.1.24
```

Run `go mod tidy` and `make build` after updating. The Makefile's auto-derivation keeps `minFormaeVersion` in `formae-plugin.pkl` at `0.84.0`; no behavioural change for plugins that do not adopt `attachesTo`.

***

## April 2026

### conformance-tests v0.2.3

*Released April 27, 2026. Requires plugin ≥0.2.1*

**Feature**: Configurable timeouts for the OOB-delete CRUD phase and the OOB plugin RPCs

Two timeouts that previously fired in the middle of healthy long-running operations on slow backends are now configurable, and one of them has a much higher default. Slow managed-Kubernetes resources (`AWS::EKS::Cluster`, `OVH::Kube::Cluster`, etc.) were the main motivator: they were failing conformance not because of bugs, but because the harness gave up before the cloud API finished.

| Env var                          | Bounds                                                                                                                                                                                                          | Default                                             | Override when                                                                                                         |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `FORMAE_TEST_OOB_TIMEOUT`        | The OOB Create / OOB Delete plugin RPC waits inside `retryOnRecoverable` (single attempt of `waitForOperationProgress`). Used by both the discovery test's CreateOOB step and the CRUD test's OOB-delete setup. | **30 minutes** (raised from a hardcoded 10 minutes) | A single Create/Delete RPC to your plugin can legitimately take longer than 30 minutes. Most plugins won't need this. |
| `FORMAE_TEST_OOB_DELETE_TIMEOUT` | Step 24 of the CRUD test: the post-sync inventory-tombstone wait, *after* the plugin's Delete RPC has already returned.                                                                                         | **2 minutes** (unchanged; was previously hardcoded) | Sync → tombstone propagation in your agent setup is slow.                                                             |

Both default to values that match what we currently exercise on AWS / OVH; raise them only if you observe timeouts on a specific resource, with a value in **integer minutes** (matches the `FORMAE_TEST_TIMEOUT` and `FORMAE_TEST_DISCOVERY_TIMEOUT` convention).

The two knobs are independent: they bound different waits in different phases and can be set independently in CI.

Pairs with the AWS plugin fixes in [formae-plugin-aws#47](https://github.com/platform-engineering-labs/formae-plugin-aws/pull/47), which gets `eks-cluster` off the timeout list.

**Migration from v0.2.2**: Bump the dependency:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
go get github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests@v0.2.3
go mod tidy
```

No code changes required. Existing conformance test suites continue to work unchanged; the raised default for `FORMAE_TEST_OOB_TIMEOUT` only affects how long the harness is willing to wait for slow-provisioning resources before failing.

***

### plugin v0.2.1

*Released April 18, 2026. Requires formae ≥0.84.0*

**Feature**: Plugin compatibility checks

Plugins now verify agent compatibility at startup. If the agent version is older
than the SDK's minimum requirement, the plugin exits with a clear error message
instead of failing with cryptic wire protocol errors. The agent also checks
plugin compatibility during discovery and skips incompatible plugins with
actionable warnings.

The SDK exports two constants, `MinFormaeVersion` and `SDKVersion`, that
plugins can use in build tooling. The `make build` target in the
[plugin template](https://github.com/platform-engineering-labs/formae-plugin-template)
now auto-generates `minFormaeVersion` in the manifest from the SDK constant,
removing the need for plugin authors to set it manually.

**Feature**: Conformance test harness handles binary download

The conformance test harness now downloads the formae binary automatically via
the orbital package manager when `FORMAE_BINARY` is not set. PKL dependency
resolution is also handled by the harness. Plugin authors no longer need the
`scripts/run-conformance-tests.sh` shell script; the Makefile conformance
targets call `go test -tags=conformance` directly. Future changes to the
download mechanism or PKL resolution will flow through `go get` without
requiring manual script updates.

**Migration from v0.2.0**: Update your `go.mod`:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
go get github.com/platform-engineering-labs/formae/pkg/plugin@v0.2.1
go get github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests@v0.2.1
go mod tidy
```

Optionally update your Makefile to match the
[template](https://github.com/platform-engineering-labs/formae-plugin-template/blob/main/Makefile)
(simplified conformance targets, broadened schema install, auto-generated
`minFormaeVersion`) and delete `scripts/run-conformance-tests.sh`.

***

### plugin v0.2.0

*Released April 16, 2026. Requires formae ≥0.84.0 and Go 1.26+*

**Breaking change.** The wire protocol between the agent and plugins has changed from EDF struct encoding to MessagePack. **All plugins must be recompiled** against this version. Plugins built against older SDK versions will fail with message decoding errors.

**Change**: MessagePack serialization replaces EDF

The internal wire protocol between the agent and plugins now uses MessagePack
with zstd compression instead of Ergo's native EDF struct encoding. This is
a foundational change that enables backwards-compatible schema evolution:
adding new fields to SDK types no longer breaks existing plugins. Previously,
any field addition to a message type required all plugins to be recompiled
simultaneously.

Plugin developers do not need to handle serialization directly; it is
transparent. The `CompressResource()` and `CompressJSON()` helpers from v0.1.21
are no longer needed and have been removed; compression is now handled at the
protocol layer.

**Change**: Simplified EDF type registration

`RegisterSharedEDFTypes()` now registers 13 top-level message types instead of
the \~40 dependency-ordered types required previously. Nested types are handled
by MessagePack, eliminating the registration ordering issues that plugin
authors occasionally hit when adding new message types.

**Feature**: `Configurable` interface for plugin-specific configuration

Plugins can now receive custom configuration from the user's `formae.conf.pkl`.
Implement the optional `Configurable` interface to accept plugin-specific
settings beyond the standard rate limits and discovery filters:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
type Configurable interface {
    Configure(config json.RawMessage) error
}
```

Define your configuration schema in `schema/Config.pkl` extending
`BaseResourcePluginConfig`, and users can set plugin-specific fields in the
`agent.resourcePlugins` block of their configuration. See

**Change**: Configuration types moved to `pkg/model`

`RateLimitConfig`, `MatchFilter`, `FilterCondition`, and `LabelConfig` have
moved from `pkg/plugin` to `pkg/model`. Update your imports accordingly:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// Before
plugin.RateLimitConfig{...}

// After
import pkgmodel "github.com/platform-engineering-labs/formae/pkg/model"
pkgmodel.RateLimitConfig{...}
```

**Feature**: Discovery filters and label configuration

Two new methods on the `ResourcePlugin` interface let plugins declaratively
shape discovery behavior:

* `DiscoveryFilters()`: return filters to exclude specific resources from
  discovery (e.g., Kubernetes-owned resources in AWS)
* `LabelConfig()`: configure how human-readable labels are extracted from
  discovered resources using JSONPath queries

Both methods are required. Plugins that don't need filtering or custom labels
should return `nil` and `pkgmodel.LabelConfig{}` respectively.

**Change**: Ergo framework upgraded to v3.2.0

The Ergo actor framework has been upgraded from v3.1.0 to v3.2.0. This is
handled transparently by the SDK; plugin developers do not need to interact
with Ergo directly.

**Fix**: Ergo banner no longer printed in plugin processes

Plugin processes no longer print the Ergo Framework ASCII banner on startup,
keeping plugin logs clean.

**Fix**: Stack overflow on self-referencing sub-resource types

`reverseSubResourceFieldMapping` now tracks visited classes and breaks the
recursion on self-referencing types (for example, BigQuery's `SchemaField`).
Extract and PKL generation no longer hit a stack overflow when plugins expose
sub-resources that recurse into their own type.

***

### conformance-tests v0.2.2

*Released April 18, 2026. Requires plugin ≥0.2.1*

**Fix**: Correct orbital hub URL and version extraction

Fixed the hub URL used for downloading formae (was pointing to the old
`binaries/repo.json` endpoint instead of the orbital repository). Also fixed
version extraction to parse the formae version from `--version` output correctly.

***

### conformance-tests v0.2.1

*Released April 18, 2026. Requires plugin ≥0.2.1*

**Feature**: Built-in binary download via orbital

The harness now downloads the formae binary automatically when `FORMAE_BINARY`
is not set, using the same orbital package manager that powers `formae update`.
This eliminates the need for `scripts/run-conformance-tests.sh`. PKL dependency
resolution (version pinning + `pkl project resolve`) is also handled internally.

**Improvement**: Graceful handling of missing PklProject files

`ResolvePKLDependencies` now skips directories without a `PklProject` file
instead of failing, supporting minimal plugins that may not have
`testdata/PklProject`.

***

### conformance-tests v0.2.0

*Released April 16, 2026. Requires plugin ≥0.2.0 and Go 1.26+*

**Feature**: Per-plugin typed configuration in test harness

The test harness now generates configuration using typed plugin imports
(`import "plugins:/<Plugin>.pkl"`) and per-plugin `resourcePlugins` blocks,
matching the new configuration format in formae ≥0.84.0. Plugins must have a
`schema/Config.pkl` extending `BaseResourcePluginConfig` for the harness to
generate correct configuration.

**Feature**: Configurable discovery resource types via environment variable

The `FORMAE_DISCOVERY_TYPES` environment variable allows overriding which
resource types the harness configures for discovery tests, without modifying
test code.

**Feature**: Step-level result matrix in test output

Conformance test output now includes per-phase outcomes via the new
`ResultCollector`: 8 CRUD phases (Create, Verify, Extract, Sync, Update,
Replace, Destroy, OOB Delete) and 4 discovery phases (CreateOOB, Register,
Discover, Verify). This makes it easy to see at a glance which phase failed
instead of parsing the full test log.

**Change**: Top-level `pluginDir` in generated configuration

The test configuration now includes `pluginDir` at the top level, matching the
configuration schema change in formae ≥0.84.0.

**Fix**: Discovery NativeID matching for charts with multiple resource types

The discovery test previously took the NativeID from the last-created resource
and the resource type from the last resource in the PKL output, which could
differ when dependency order and PKL output order diverged (for example, a
cert-manager chart whose last PKL resource is a Service but last-created
resource is a Deployment). The harness now searches backwards through created
resources for the one matching the extracted type, so discovery tests for
multi-resource charts no longer wait for a non-existent combination.

***

## March 2026

### plugin v0.1.21

*Released March 28, 2026*

**Breaking change.** The internal message format between the agent and plugins has changed. **All plugins must be recompiled** against this version. Plugins built against older SDK versions will fail with message decoding errors during synchronization and resource operations.

**Change**: Compressed plugin messages

Resource fields in all plugin operation messages (`ReadResource`,
`CreateResource`, `UpdateResource`, `DeleteResource`) are now
gzip-compressed for transport. This resolves an issue where resources with
large schemas (e.g. Kubernetes Pod, AWS ECS TaskDefinition) could exceed
internal transport limits, causing silent failures during synchronization
and updates.

Plugin developers do not need to handle compression; it is transparent.
If you construct `ReadResource` or `DeleteResource` directly (e.g. in
test utilities), use `plugin.CompressResource()` for the `Resource` field
and `plugin.CompressJSON()` for property fields.

**Fix**: Plugin error messages now included in failure responses

When a plugin's CRUD method returns an error, the error text is now
included in the response. Previously the message was empty, making
failures difficult to diagnose from the CLI or API.

***

### conformance-tests v0.1.40

*Released March 28, 2026*

**Feature**: Out-of-band delete detection test

The CRUD lifecycle now verifies that the agent correctly detects when a
resource is deleted outside of formae.
After the standard destroy cycle, the test recreates the resource, deletes
it directly via the plugin (bypassing formae),
triggers a sync, and verifies the resource is removed from inventory.

**Feature**: Collection-aware `hasProviderDefault` for Mapping fields

Property comparison now correctly handles `hasProviderDefault` on
Mapping-type fields, preventing false test failures when cloud providers
populate extra keys in map fields.

***

### conformance-tests v0.1.25

*Released March 17, 2026*

**Feature**: Regex support for test filter

The `TEST=` parameter now supports `/…/` delimited regular expressions for
flexible test group selection. For example, `TEST='/.*-chart/'` selects all chart
tests and `TEST='/^cluster.*/'` selects all cluster-scoped resources. Regex and
literal segments can be mixed in a single comma-separated filter (e.g.,
`TEST='/.*-chart/,namespace'`). Invalid regex patterns cause immediate failure
with a clear error message. See
[Filtering Tests](/plugin-development/tutorial/11-conformance#filtering-tests) for details.

**Feature**: Configurable discovery timeout

The discovery test timeout is now configurable via the `FORMAE_TEST_DISCOVERY_TIMEOUT`
environment variable (in minutes). Previously hardcoded to 2 minutes, this can now be
increased for plugins with many resource types or chart-based test cases that need more
time for discovery to complete. See
[Environment Variables](/plugin-development/tutorial/11-conformance#environment-variables) for details.

***

### conformance-tests v0.1.24

*Released March 12, 2026*

**Fix**: Retry create/delete operations on recoverable errors

The test harness now retries `CreateResource` and `DeleteResource` operations
when the plugin returns a recoverable error, instead of failing the test
immediately. This reduces flaky test failures caused by transient cloud provider
errors during conformance runs.

***

### conformance-tests v0.1.23

*Released March 10, 2026*

**Fix**: Honor `@HasProviderDefault` annotation in property comparison

Previously, conformance tests ignored extra fields returned by plugins, which
could hide issues where plugins return unexpected data. Tests now validate that
extra fields in the actual state are only allowed if marked with
[`@HasProviderDefault`](/plugin-development/reference/schema#hasproviderdefault) in the schema.
Unexpected extra fields will now correctly fail the test.

***

### conformance-tests v0.1.22

*Released March 9, 2026*

**Fix**: Property comparison for Listing fields containing resolvables

Previously, conformance tests would fail for schemas with `Listing` fields where
elements contained resolvable references (e.g., `subjects: Listing<Subject>` with
a resolvable `namespace` property inside `Subject`). The test framework now
correctly compares these nested resolvables.

***

## February 2026

### plugin v0.1.14

*Released February 23, 2026. Requires formae ≥0.82.2*

**Fix**: Transitive `pkg/model` dependency

Fixed incorrect transitive dependency on `pkg/model` that caused EDF
serialization errors. The `pkg/model` dependency is now pinned to v0.1.5 across
all SDK modules.

***

### conformance-tests v0.1.21

*Released February 23, 2026. Requires plugin ≥0.1.14*

**Fix**: Transitive `pkg/model` dependency

Bumped `pkg/model` to v0.1.5 to match `plugin v0.1.14`, fixing EDF
serialization errors caused by mismatched transitive dependencies.

***

### plugin-template

**Feature**: `verify-schema` CI job

Added a `verify-schema` CI job to the template that validates PKL schemas can be
generated correctly, catching schema errors before they reach plugin releases.

***

### conformance-tests v0.1.20

*Released February 23, 2026*

**Fix**: Nested Resolvables in SubResource map comparison

`compareProperties()` used flat string comparison for non-Resolvable,
non-array values. When a SubResource map (e.g., `ResourceLifecycleConfig`)
contained a nested Resolvable reference, the entire map was string-compared
`$visibility:Clear` from Pkl eval vs `$value:arn:...` from resolved inventory,
causing false test failures.

Added recursive `compareMap()` that delegates to `compareResolvable()` for
nested Resolvables, `compareArrayUnordered()` for arrays, and recurses into
sub-maps.

***

### plugin v0.1.13

*Requires formae ≥0.82.0*

**Feature**: HasProviderDefault field hint

New `hasProviderDefault` annotation for fields where cloud providers assign
default values (e.g., S3 bucket encryption, SQS visibility timeout).

```pkl theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.FieldHint { hasProviderDefault = true }
bucketEncryption: BucketEncryption?
```

When a field with this hint exists in actual state but is not specified by the
user, formae accepts the provider's default instead of generating a "remove"
operation. This prevents oscillation during reconcile cycles.

See the [schema reference](/plugin-development/reference/schema#hasproviderdefault) for details.

***

### conformance-tests v0.1.19

*Released February 10, 2026*

**Improvement**: Robust property comparison for arrays, resolvables, and string escaping

This release significantly improves how the conformance test framework compares
expected vs actual resource properties:

* **Order-independent array comparison**: All array properties are now compared
  as unordered sets. Elements are serialized to JSON and sorted before
  comparison, eliminating false failures caused by CloudControl returning arrays
  in a different order than specified in PKL.

* **Resolvable handling inside arrays**: Arrays containing resolvable
  references (e.g., Tags with resolved values) are now matched element-wise
  using metadata keys (`$label`, `$type`, `$stack`, `$property`). Resolvables
  are handled gracefully after extraction, where `$value` may no longer be
  present.

* **String escaping normalization**: JSON/PKL round-trip differences in
  backslash escaping no longer cause false mismatches. Extraction comparison
  now reuses the same comparison logic as inventory comparison, benefiting from
  all the above improvements.

***

### plugin v0.1.12

*Released February 6, 2026*

**Fix**: pkl-go race condition

Fixed a race condition in pkl-go's `NewProjectEvaluator` where a late message
from the pkl subprocess could kill the evaluator manager's listen loop, causing
evaluation failures. This primarily affected CI environments under resource
pressure.

***

### plugin v0.1.11

*Released February 5, 2026*

**Feature**: Plugin telemetry metrics

Plugins now export Ergo actor framework metrics via OpenTelemetry, providing
visibility into plugin process health and performance.

***

### conformance-tests v0.1.14

*Released February 4, 2026*

**Feature**: Configurable test timeout

Override the default 5-minute timeout for resources that take longer to
provision:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
make conformance-test TIMEOUT=900
```

**Note:** Requires updating your Makefile from the
[plugin template repository](https://github.com/platform-engineering-labs/formae-plugin-template).

***

**Feature**: Config directory support

You can now create a `config/` directory under `testdata/` containing shared Pkl
code (such as targets or stacks). Files in this directory are not picked up as
test cases by the conformance test framework.

***

## January 2026

### conformance-tests v0.1.13

*Released January 31, 2026. Requires plugin v0.1.9.*

**Feature**: Parallel test execution with configurable Ergo ports

Run conformance tests in parallel to significantly reduce test execution time:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
make conformance-test PARALLEL=10
```

When running parallel tests, multiple formae agent instances need unique ports
for Ergo node communication. This release adds `ergoPort` configuration to
prevent port conflicts.

**Note:** Requires updating your Makefile from the
[plugin template repository](https://github.com/platform-engineering-labs/formae-plugin-template).

***

### conformance-tests v0.1.12

*Released January 30, 2026*

**Feature**: Parallel test execution support

Added infrastructure for running conformance tests in parallel via
`FORMAE_TEST_PARALLEL` environment variable.

***

### conformance-tests v0.1.10

*Released January 30, 2026*

**Conformance test improvements**

* Faster and more reliable discovery tests in CI environments
* Extract validation step now runs correctly for all extractable resources
* Improved error messages when validations fail

***

### plugin v0.1.9

*Released January 31, 2026*

**Feature**: Configurable Ergo port

Plugins now read `FORMAE_ERGO_PORT` environment variable to configure Ergo
acceptors, enabling parallel test execution.

***

### plugin v0.1.8

*Released January 30, 2026*

**Fix**: Plugin log levels now preserved

Plugin logs were incorrectly routed through stderr, causing the formae agent to
treat all plugin logs as errors regardless of their actual log level. Logs now
route through stdout with correct log levels preserved.
