Changelog
View SourceFormat: Keep a Changelog. Versioning: SemVer.
[0.11.0] - 2026-08-19
Changed
- Capability discovery is now DHT record-based, replacing the pubsub
_mesh.cap.announcebroadcast. On capability register (and a 30s republish tick) a service writes one signedprocedure_advertisementper capability to the mesh DHT (advertiser = the service's key, serving_station = a connected station, procedure_uri = realm-namespaced capability name).lookup/1resolves by reading those records viamacula:find_records/2, verifying each signature, and returns{ok, [#{advertiser, serving_station}]}— a consumer then dials one of those stations directly (direct-dial discovery, no multi-hop). - Requires macula
~> 8.2(was~> 8.0): usesfind_records/2,read_procedure_advertisement/1,procedure_key/1from macula 8.2.0.
Added
hecate_om_identity:keypair/0— the service's retained stable signing keypair, or{error, no_keypair}for an ephemeral service (which is then not advertised and stays invisible to DHT discovery, by design).
Removed
- The pubsub
_mesh.cap.announce' publish/subscribe path andpeers/0. There were no callers of the oldlookup/1' summary shape.
[0.10.0] - 2026-08-13
Changed
Requires macula
~> 8.0(was~> 7.0). This is the release that lets a hecate-om service say WHY it refused.macula 8.0.0 stopped answering
{error, {call_error, 16#0F, unknown_error}}when a handler returns{error, Reason}and now returns the handler's own reason.0x0Fis the code the SDK stamps when a handler says no, so it never meant "unknown error" in practice — it meant a service had refused and could not tell you why. Every refusal in the world arrived as the same three words.%% handler handle(_) -> {error, <<"hold_full">>}. %% caller, on 7.x {error, {call_error, 15, unknown_error}} %% caller, on 8.x {error, <<"hold_full">>}Measured rather than assumed: a two-service torture across two live stations with no direct edge fails this on 7.0.0 with exactly the old constant and passes on 8.0.0 with the reason intact.
Consumer impact. Nothing in this library matches the old shape — there is no
call_errororunknown_erroranywhere insrc/,priv/ortest/. A consumer that pattern-matches{error, {call_error, _, _}}on a REFUSAL will stop matching; one that matches{error, _}is unaffected. Transport failures keep the{call_error, Code, Name}shape, so only the handler-refusal case changes.⚠ A binary reason now crosses the wire verbatim; non-binary reasons arrive as printed binaries. See macula CHANGELOG 8.0.0.
Added
storevariable onrebar3 new hecate_service, off by default. Empty generates a storeless service exactly as before, which is what most services want.store=1generates the whole thing at once:store_id/0anddata_dir/0, a store named<name>_store, theevoqadapter block inconfig/sys.config.src, a data volume andHECATE_DATA_DIRin the compose file, and three boundary guards keeping them in step.It exists because adding a store by hand is three things and not one, and omitting the third crash-loops the node before any service code runs. A sibling service put two of three fleet nodes into a boot loop by exporting the callbacks without adding the
evoqblock, which raises{not_configured, event_store_adapter}at release boot. The generated README says the same thing in both branches, so a service scaffolded without a store is told what adding one really costs.⚠ The value must be exactly one character, so
1and notyes. rebar3 passes template variables as strings and mustache iterates a string as a list, so a longer value repeats every conditional block once per character. That is a limitation of the template engine rather than a preference, it is documented on the variable itself, and it fails loudly at the firstrebar3 compilewithspec for store_id/0 already definedrather than shipping anything.The generated suite now checks that the two OTP pins agree, and that you are running what they name.
rebar3 new hecate_servicehas always pinned the release in two files, theContainerfileand.github/workflows/lint.yml, and 0.9.0's own commit message said they must agree. Nothing enforced it.A sibling service shipped with its
Containerfileon 27 while development ran on 28, so a localrebar3 eunitmeant "passing on 28" and nothing more, CI failed for three commits on a crash that does not occur on 28 at all, and because the image build is a separate workflow the image reached the fleet regardless.The generated
*_service_tests.erlnow reads both files and compares them againsterlang:system_info(otp_release). It fails rather than warns when the running VM differs, because developing on a release you do not ship makes a green suite mean less than it appears to. Moving to another release means moving both pins, which is the point of having them.No change to
src/.
Fixed
README no longer claims services never run on user laptops. It said Layer-2 services "run on realm infrastructure nodes ... not on user laptops. They are institutions, not user agents", in the opening paragraph and again in the layering diagram. That is a deployment policy for the realm's own shared services, stated as if it were a property of this substrate, and it is the wrong way round: a hecate-om service is edge-first. It dials out to a
macula-stationover QUIC, needs no inbound port and no public address, and reaches its peers through the station. Running one on a laptop is the ordinary case, not an exception.What the sentence was reaching for is the identity rule, which stands and is now stated on its own: a service answers with its own service-principal credential chaining to a realm root, never as the human whose machine it runs on. Placement is a deployment decision; identity is not.
No change to
src/.
[0.9.0] - 2026-07-31
No change to src/. This release is the scaffold, its guard, and a
documentation pass; consumers of the library itself get the same behaviour
they had on 0.8.0.
Added
rebar3 new hecate_service, a real rebar3 template inpriv/templates, generating a repository that compiles, tests and deploys: the OTP application and supervisor, the six-callback service module, a eunit suite asserting the contract, a relx release, aContainerfile, both CI workflows, an executablescripts/health.sh,deploy/docker-compose.yml, and the usual documentation.scripts/install-templates.sh, because rebar3 only finds custom templates under~/.config/rebar3/templatesand an empty directory has no dependency to carry them there.hecate_service_template_SUITE, which generates a service for real throughrebar3 newand then compiles it against this library, so the-behaviour(hecate_om_service)attribute checks all six callbacks. It also asserts the file set, thathealth.shis executable, that no unrendered variable survives, and that GitHub Actions expressions are intact..github/workflows/lint-and-test.yml. This repository had no CI at all, which is how the old templates drifted unnoticed.
Changed
scripts/scaffold-service.shis now a wrapper over the template and takes the service name once, deriving the snake_case application name from the kebab-case repository name. It previously rendered a handful of files with sed and left you to writerebar.config, the.app.srcand the supervisor by hand, so a scaffolded service did not compile.
Fixed
- The hex package links and the ex_doc
source_urlpointed at Codeberg. GitHub has been canonical since 2026-07-26. guides/container_deployment.mddescribed a deployment that does not exist: system-wide Podman Quadlets reconciled byhecate-gitops, ahecate-realm-adminCLI, a loopback-published health port. It referenced three template files that no longer exist. Rewritten to describe what the generated service actually does, with a closing section naming what is intended rather than built.guides/service_anatomy.mdshowed a repository layout withquadlet/, amanifest.jsonand a flatsrc/, none of which the scaffold produces.- The README's status line claimed v0.5.0.
Removed
- The old
templates/directory. It had drifted from the estate it was meant to serve: a Quadlet unit that nothing on the fleet uses (the beam nodes run docker compose under a pull-based reconciler),TODOcomments in place of two callbacks, a store-backed service as the default in a mostly producer-only estate, anidentity_specclaiming two actions and a wildcard resource for a service that could exercise none of them, and no test file at all. Nothing consumed it: thehecate-om scaffoldCLI its own README documented was never written.
[0.8.0] - 2026-07-26
Changed
Requires macula
~> 7.0(was~> 6.0). macula 7.0.0 teaches the canonical encoder floats (IEEE 754 binary64, RFC 8949 major type 7), so services publish raw float telemetry again and stop scaling to integers to get a number past our own codec.This is a WIRE change upstream: a peer on macula 6.x finds no clause for major 7 and rejects a frame carrying a float. Both ends of any topic that will carry floats must be on 7.x, so roll stations and services together rather than piecemeal.
0.7.0 was the stopgap that made the old restriction loud instead of silent. This is the release that removes the restriction. See macula CHANGELOG 7.0.0.
[0.7.0] - 2026-07-26
Changed
Requires macula
~> 6.0(was~> 5.1). BREAKING for consumers, because macula 6.0.0 changed publish behaviour and that passes straight through:macula:publish/4,5now returns{error, {unsupported_payload_type, Type, Path}}where it previously returnedok, for raw floats, tuples, colliding map keys, out-of-range integers and oversized payloads.Those publishes were not working before. A float was silently rewritten as a six-decimal text string, and an unrepresentable term killed the shared peering connection while its sender was told
ok. Services publishing raw floats must scale to integers (micro-units) or send binary strings.No wire-format change, so a service on 0.7.0 interoperates with stations and peers on older macula; the guard is entirely sender-side.
See macula CHANGELOG 6.0.0.
[0.6.0] - 2026-07-14
Fixed
GET /healthis now actually served.hecate_om_health_handlerdefined the route but nothing ever mounted it on a listener, so/healthwas dead code and every hecate_om service reported unhealthy to Podman/k8s (nothing boundhealth_port).hecate_om_supnow starts a Cowboy listener onhealth_port, dispatching to the handler — gated on a validhealth_port, so a service that wants no HTTP health endpoint simply omits the config.snapshot/0already calls the registered service'shealth/0live, so a healthy service returns 200.
Changed
maculadependency bumped to~> 5.1(connect-hang fix).
[0.5.0] - 2026-07-04
Added
- Optional
store_integrity/0service callback. When exported,hecate_om:boot/1threads its value (disabled, or#{enabled => true, key_source => ...}) into the reckon-db store config, enabling per-store HMAC event tamper-resistance. Defaults todisabled(backward compatible).hecate_om_store:ensure/5+ensure_store/5accept the integrity config explicitly.
[0.4.0] - 2026-07-02
Added
- Optional
store_mode/0service callback —single(default) orcluster. When a service exports it,hecate_om:boot/1auto-starts its reckon-db store in that mode;clusterenables reckon-db discovery + Ra clustering so the store spans every node that starts the samestore_id. Newhecate_om_store:ensure/4+ensure_store/4carry the mode; the/2and/3arities keep defaulting tosingle(backward compatible). Previously the auto-started store was alwayssingle, with no override.
[0.3.4] - 2026-06-24
Fixed
hecate_om_store:ensure/3andensure_store/3were not exported in 0.3.3, sohecate_om:boot/1's cross-module call crashed withundefat boot ({hecate_om_store_failed, ..., undef}). The store-index wiring added in 0.3.3 was therefore dead on arrival. Added both to-export.
[0.3.3] - 2026-06-24
Added
- Optional
store_indexes/0service callback. When a service exports it alongsidestore_id/0+data_dir/0,hecate_om:boot/1installs the returned reckon-db secondary index declarations (e.g.{payload, Key},{payload_hash, [Keys]}) on the auto-started store. Previously the auto-wired store was created with no indexes, so a service that also declared indexes via its ownstart_storecall hit{already_started}and its declarations were silently dropped — CCC payload indexes never got registered.hecate_om_store:ensure/3andensure_store/3carry the index list; the/2arities delegate with[].
Changed
- Bumped the reckon-db stack pins to the current ecosystem:
reckon_db ~> 5.4(was~> 2.3— needed for the#store_config.indexesfield),evoq ~> 1.21(was~> 1.15),reckon_evoq ~> 2.6(was~> 2.1).
[0.3.2] - 2026-06-03
Added
MACULA_STATION_SEEDSenv override for station seeds. When set (comma-separated station URLs), it takes precedence over thestation_seedsapp env inhecate_om_identity:configured_seeds/0; empty/unset falls back to the app env. Lets one deployed image dial a distinct station per instance without a rebuild (e.g. one bot per node, one station each), matching the existing seed-via-env convention.
[0.3.1] - 2026-06-01
Fixed
- Mesh connect no longer gated on the service-principal cert.
hecate_om_identity:attach_client/1 previously short-circuited to
undefined(never calling macula:connect/2) whenever the cert file was absent, leaving every cert-less service permanentlyno_client. The cert was a spurious gate: it is never passed toconnect(the SDK auto-generates an ephemeral peering identity for empty opts), only loaded and held forservice_cert/0/ the v2 realm-membership swap-in. Connect now keys off configuredstation_seeds, not cert presence. - Connect is deferred off the init path and retried. At boot
hecate_omcould start before the macula SDK app was fully up; a single inline connect raced it and lost.init/1now schedulesself() ! connect, retries every?RECONNECT_MSuntil a pool attaches, monitors the pool, and re-attaches if it later dies.
Added
- Optional
identity_key_pathenv: when set + loadable, the service peers under a stable on-disk macula-native keypair (consistent node id across restarts) via#{identity => KeyPair}; otherwise the SDK auto-generates an ephemeral identity. Identity is for peering, not authorization.
[0.3.0] - 2026-05-19
Added
hecate_om_storemodule: canonical reckon-db + evoq wiring helper. Encapsulatesreckon_db_sup:start_store/1+ 30s readiness wait +evoq_store_subscription:start_link/1. The pattern documented as mandatory inhecate-corpus/skills/ANTIPATTERNS_EVENT_SOURCING.mdnow lives in one place.- Optional callbacks on
hecate_om_service:store_id/0anddata_dir/0. When a service module exports both,hecate_om:boot/1auto-runs the canonical wiring beforeServiceMod:start/1. - New template
templates/sys.config.src.tmplwith the canonical reckon_db + evoq blocks. scripts/scaffold-service.shnow rendersconfig/sys.config.srcalongside the service modules.
Changed
_service.erl.tmplincludes the optionalstore_id/0+data_dir/0callbacks by default; producer-only services remove both.rebar.configadds reckon_db, evoq, reckon_evoq as deps so services usinghecate_omget the store-wiring stack for free. Producer-only services inherit the image-size cost but not the runtime cost (nothing starts unless the service module declaresstore_id/0).
Why
Each new CMD/PRJ service was rediscovering the canonical reckon-db
wiring (or, more often, missing pieces of it). The parksim trio
shipped without {evoq, [{event_store_adapter, ...}]} and without
any reckon_db_sup:start_store/1 call, leaving evoq in default
in-memory mode despite being configured as event-sourced. This
release moves the pattern into the library so future services pick
it up just by exporting two callbacks.
[0.2.0]
Added
- Initial scaffold:
hecate_om_servicebehaviour, helpers for identity claim, capability advertise, and/healthendpoint. - Templates for
Containerfile, Quadlet unit,manifest.json, and CI workflow. - Guides: service anatomy, identity model, container deployment.
Planned
- UCAN-delegated identity wiring once
hecate-realmissues service principals - Common Test framework helpers for service test suites
[0.1.0] - YYYY-MM-DD
Not yet released.