← All posts

Cure v0.27.0 :: See Your System Breathe

by Aleksei Matiushkin

release observability temporal session-types otel crdt hyperlinks playground

v0.26.0 closed the OTP application loop. You could declare an app, describe [application] and [release] in Cure.toml, and package the whole thing as a bootable release with cure release. What you could not yet do was watch that application breathe once it was running, or prove that the FSMs it owns satisfy the live-ness and safety properties you had in mind when you drew them. v0.27.0 is the observability-and-verification release that delivers both halves of that story.

The theme is See Your System Breathe. Every piece below either makes a running Cure application observable or makes the language's guarantees surface to the user earlier.

The trajectory

  • v0.25.0 -- typed supervision trees (actor, sup, the Melquiades Operator).
  • v0.26.0 -- OTP application lifecycle (app, releases, Std.App).
  • v0.27.0 -- observability + verification around the running application.

Observability

Three new surfaces make a running Cure release introspectable.

Cure.OTel -- span bridge

Cure.OTel is an OpenTelemetry-compatible span emitter on top of Cure.Pipeline.Events. It is started explicitly:

Cure.OTel.start(
  exporter: &MyApp.Exporter.record/1,
  service_name: "my_app",
  sample_ratio: 1.0
)

Once started, every event on every pipeline stage becomes a span. Library code can also open manual spans:

Cure.OTel.span("cure.actor.send", %{"inbox" => "Ping.Pong"}, fn ->
  pid |> send(message)
end)

Nested spans share a trace id and chain via :parent_span_id. inject_ctx/1 / extract_ctx/1 carry the span context across process boundaries; drop the token into the metadata field of a Melquiades Operator message and the receiver continues the trace.

When opentelemetry_api is loaded, spans are forwarded there. Otherwise the bundled Cure.OTel.MemoryExporter stashes every span into a public ETS table (:cure_otel_spans) ready for test assertions. Zero-runtime-cost when the bridge is not started.

cure top -- snapshot observer

cure top
watch -n1 mix cure.top       # low-tech live view
cure top  2026-04-21T15:20:00Z              procs=85  reductions=12345
Supervisors (1)
  - Cure.Sup.Atelier.Root  (2 children)
Actors (2)
  - painter_1 (Cure.Actor.Painter)  mbox=0  mem=9184  reds=301
  - curator_1 (Cure.Actor.Curator)  mbox=0  mem=9032  reds=212
FSMs (1)
  - exhibit_1 (Cure.FSM.Exhibit)  state=Closed  events=0  uptime_ms=42

Reads from the live ETS registries owned by Cure.Sup.Runtime, Cure.Actor.Runtime, and Cure.FSM.Runtime. The snapshot API (Cure.Observe.Top.snapshot/0 + render/2) is also consumable from code and from Livebook.

cure trace -- typed tracer

cure trace Cure.Std.List.map/2 --duration 10

Every call and return is formatted through inspect/2 and, when a compile-time signature is known, annotated with the Cure type of each argument and the declared effect set:

call #PID<0.212.0> Cure.Std.List.map/2([1, 2, 3] : List(Int), #Function<...>)  [pure]
return #PID<0.212.0> Cure.Std.List.map/2 -> [2, 4, 6] : List(Int)

Signatures live in an ETS registry (Cure.Observe.Trace.Registry) that the type checker can populate during a project compile. Missing entries fall back to plain inspect/2.

Verification

Two new verification surfaces plug directly into the FSM / actor model.

Cure.Temporal -- LTL over FSMs

Write the properties you care about in a small, readable DSL:

always eventually Open
never Dead
always (Locked -> eventually Unlocked)
Locked until Unlocked

Cure.Temporal.Parser parses; Cure.Temporal.Formula is the LTL ADT; Cure.Temporal.Checker does the model checking over an FSM's transition graph:

&lbrace;:ok, f&rbrace; = Cure.Temporal.Parser.parse_one("always eventually Open")

model = Cure.Temporal.Checker.from_fsm(
  [%&lbrace;from: "Closed", to: "Open"&rbrace;,
   %&lbrace;from: "Open",   to: "Closed"&rbrace;],
  "Closed"
)

Cure.Temporal.Checker.check(f, model)
# => &lbrace;:ok, :holds&rbrace;

Safety properties (always P, never P) are checked via exhaustive BFS over the reachable closure; liveness properties (eventually P) via forward BFS; P until Q chains both. Failed properties return a concrete counterexample trace rather than an opaque "failed" atom. Default bound is length(states) * 8, overridable via bound: n.

Cure.Protocol -- session types for actors

Declare a session-typed binary protocol between two roles:

protocol Ping.Pong with Client, Server
  Client -> Server: Ping
  Server -> Client: Pong(Int)
  end

Cure.Protocol.Parser, Cure.Protocol.Script, and Cure.Protocol.Verifier give you:

  • Structural role-usage and role-membership checks (E056).
  • Reachability of every projected state from the initial state.
  • Cure.Protocol.Script.project/2 to get an FSM-style transition list for a single role -- ready to feed back into Cure.Temporal.Checker for liveness properties on the projection.
&lbrace;:ok, script&rbrace;  = Cure.Protocol.parse_and_verify(dsl)
client_view    = Cure.Protocol.project(script, "Client")
client_model   = Cure.Protocol.participant_trace(script, "Client")

Typed-hole synthesis

Placeholder ?hole in your source, cure synth suggests the fix:

cure synth --goal Int --ctx "n=Int,xs=List(Int)"
goal: Int
ctx:  %{"n" => "Int", "xs" => "List(Int)"}

Candidates:
  1. n  (cost 1, pure)
  2. Std.List.length(xs)  (cost 3, pure)
  3. n |> Std.Math.abs  (cost 3, pure)

Cure.Types.Synth.synthesise/4 walks a depth-budgeted enumeration of well-typed candidates against a goal type and a local context. Ranking is shorter-beats-longer, pure-beats-effectful, local-vars- beat-stdlib, with a seeded catalogue of common stdlib entries (Std.Core, Std.Option, Std.Result, Std.List, Std.Math, Std.String, Std.Io). Runs out of budget? The :synthesis stage emits E061 Synthesis Budget Exhausted and the CLI walks away with an empty result.

Three new stdlib modules

Std.Time

use Std.Time

fn now_iso() -> String ! Io =
  let i = Std.Time.utc_now()
  Std.Time.format_iso8601(i)

fn tomorrow(i: Instant) -> Instant =
  Std.Time.add(i, Std.Time.hours(24))

Instant (Unix microseconds), Duration, ISO 8601 parser/formatter, arithmetic, Unix conversions, smart duration constructors. Runtime is :cure_std_time.

Std.Regex

use Std.Regex

fn valid_email(s: String) -> Bool =
  match Std.Regex.compile("^[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+$")
    Ok(r)    -> Std.Regex.is_match(r, s)
    Error(_) -> false

Thin wrapper over :re: compile, compile_bang, is_match, run, scan, replace, split. Invalid patterns surface as the new E060 Regex Invalid.

Std.CRDT

Five state-based CRDTs whose merge/2 operations are commutative, associative, and idempotent -- enforced by the test suite:

  • GCounter, PNCounter -- counters.
  • ORSet -- observed-remove set.
  • LWWRegister, MVRegister -- registers.
use Std.CRDT

let painter = Std.CRDT.or_add(Std.CRDT.or_empty(), :painter, :abstract)
let curator = Std.CRDT.or_add(Std.CRDT.or_empty(), :curator, :cubism)
let merged  = Std.CRDT.or_merge(painter, curator)

Runtime bridge: :cure_std_crdt.

OSC 8 clickable-filepath errors

Cure.Term.Hyperlink wraps file paths and line numbers in OSC 8 escape sequences when the terminal supports them. The Compiler errors formatter picks it up for free:

error: type mismatch
 --> lib/app.cure:42
  | declared return type Int but body has type String

In WezTerm, Kitty, iTerm2, VTE, or Warp the filepath is clickable and jumps straight to the offending line. NO_COLOR disables emission; CURE_HYPERLINKS=0 forces it off; CURE_HYPERLINKS=1 forces it on regardless of terminal detection.

Playground

A LiveView Playground lands at /playground:

  • Two-pane editor: plain-text source on the left, Makeup- highlighted HTML preview on the right.
  • 150 ms debounced updates so typing feels immediate without saturating the socket.
  • Driven by the same Makeup.Lexers.CureLexer the REPL uses for its raw-mode highlighter.

Type-checking and sandboxed evaluation are on the roadmap for v0.28; see the Playground page for the full plan.

Auto-generated Mermaid diagrams

Cure.Doc.Mermaid renders any FSM / sup / app container as Mermaid source ready to embed in cure doc HTML output. An FSM becomes a stateDiagram-v2 with per-event labels and !/? suffixes preserved; a supervisor tree becomes a graph TD with each child's module, restart policy, and shutdown timeout; an application becomes a graph LR with the root edge and the declared extra applications.

Error catalog

Five new codes:

  • E056 Protocol Violation -- dead role, stranger role, or unreachable projected state inside a protocol declaration.
  • E059 Temporal Property Violated -- with a minimal counterexample trace.
  • E060 Regex Invalid -- bad pattern surfaced at compile or call time.
  • E061 Synthesis Budget Exhausted -- Cure.Types.Synth ran out of depth before finding a candidate.
  • E062 Temporal Target Unknown -- temporal formula references a state absent from the model.

cure explain E0NN prints the full text with examples.

examples/cure_atelier/

The canonical v0.27.0 showcase. A compact Mix project whose test suite exercises every new surface end to end:

  • Std.CRDT.ORSet for painter and curator tag sets merged without coordination.
  • Std.Time.Instant for curation timestamps.
  • Std.Regex for input validation with capture groups.
  • Cure.Protocol verifies an Atelier.Gallery session type and surfaces E056 on a deliberately broken variant.
  • Cure.Temporal specifies always eventually Open and never Open over an Exhibit FSM.
  • Cure.OTel + the MemoryExporter capture nested span chains.
  • Cure.Types.Synth suggests a fix for a hole.
  • Cure.Term.Hyperlink is exercised through the shared error formatter.

Read examples/cure_atelier/test/cure_atelier_test.exs for the most compact tour of the release.

Getting started

git clone https://github.com/am-kantox/cure-lang.git
cd cure
mix deps.get && mix test
mix escript.build
./cure version                      # Cure 0.27.0
./cure top                          # runtime snapshot
./cure synth --goal Int --ctx "n=Int,xs=List(Int)"

Read the new references on-disk: docs/OBSERVABILITY.md, docs/TEMPORAL.md, docs/PROTOCOL.md, docs/PLAYGROUND.md, the extended docs/STDLIB.md, and the full CHANGELOG.

Numbers

  • 1451 tests pass (up from 1114 pre-release; 3 doctests + 1448 tests).
  • 0 mix credo --strict issues across 238 source files.
  • 34 stdlib modules compile clean (up from 31).

What's next

The Playground's type-checker half plus the WASM target sit at the top of the v0.28 queue. The long-term radar from v0.24.0 / v0.25.0 / v0.26.0 is unchanged: monomorphisation, profile-guided optimisation, broader IDE reach, REPL-level hot reload. The repository is at github.com/am-kantox/cure-lang.