hecate-om

View Source

Hecate-over-mesh: the shared substrate every hecate-services/hecate-X service daemon stands on.

Services in this org run on realm infrastructure nodes (the BEAM cluster, dedicated relay boxes, cooperative-contributed service nodes), not on user laptops. They are institutions, not user agents — see guides/identity_model.md for the town/library metaphor that drives the identity choices.

                        hecate-om
                            
        
                                                      
   hecate-rag  hecate-llm hecate-dns hecate-git hecate-blob 

Every service is a separate OTP release shipped as an OCI container to ghcr.io/hecate-services/. hecate-om is the library they all link against to behave consistently on the mesh: the same service contract, the same manifest schema, the same health endpoint, the same identity-claim flow, the same capability-advertise pattern, the same Containerfile + Quadlet templates.

What this library is (and isn't)

It is:

  • An Erlang behaviour (hecate_om_service) — six callbacks every service implements: start/1, stop/1, health/0, capabilities/0, identity_spec/0, info/0.
  • Helpers for the bits every service needs: load the realm cert, advertise a capability via macula's bloom-channel, serve a /health endpoint, parse the standard manifest.json schema.
  • Mustache templates for the boilerplate every service repo carries: Containerfile, quadlet/<service>.container, manifest.json, release_template.

It is not:

  • A daemon. It has no application:start_phase of its own beyond the library's facade.
  • A plugin host. Services are containerised. Plugins live in hecate-daemon (different repo, different model).
  • A network library. Services talk to macula-station via the macula SDK like any other Macula client.

Layering position

Layer 4  apps        hecate-app-martha, hecate-app-rag (UI), 
                      User-facing plugins, live in hecate-daemon

Layer 3  session     hecate-daemon
                      Per-identity, plugin host, UI surface

Layer 2  services    hecate-services/hecate-rag, -llm, -dns, -git, 
                      Always-on, containerised, system-class workloads.
                      Run on realm infrastructure nodes (BEAM cluster,
                      relay boxes), never on user laptops.
                       this library is the substrate 

Layer 1  identity    hecate-realm / macula-realm

Layer 0  kernel      macula-station

See philosophy/HECATE_TIER_MODEL.md in hecate-corpus for the longer cut-criteria discussion.

The contract

-module(my_service).
-behaviour(hecate_om_service).

%% lifecycle
-export([start/1, stop/1]).

%% introspection
-export([health/0, capabilities/0, identity_spec/0, info/0]).

start(_Opts) ->
    my_service_sup:start_link().

stop(_State) ->
    ok.

%% Reported on /health endpoint. Return ok | {degraded, Reason} | {down, Reason}.
health() ->
    ok.

%% Advertised onto the mesh via hecate_om_capabilities:advertise/1.
%% Other services / plugins find you by these.
capabilities() ->
    [
        #{name => <<"my_service.do_thing">>, version => 1},
        #{name => <<"my_service.list_things">>, version => 1}
    ].

%% Tells hecate-realm what UCAN this service needs.
identity_spec() ->
    #{
        scope     => <<"my_service">>,
        actions   => [<<"publish_summary">>, <<"answer_query">>],
        resources => [<<"my_service/*">>],
        ttl_days  => 30
    }.

info() ->
    #{
        name        => <<"hecate-my-service">>,
        version     => <<"0.1.0">>,
        description => <<"What this service does in one line">>
    }.

That's the whole user-side contract. Six small functions. Everything else (release tarball, container image, Quadlet unit, manifest, health endpoint wiring, mesh advertisement) is provided by hecate-om + the template generators in templates/.

Optional: store-backed services

CMD/PRJ services that own a reckon-db event store export three more optional callbacks. When a service exports store_id/0 + data_dir/0, hecate_om:boot/1 auto-starts the store and its evoq subscription before start/1 runs — you never call reckon_db_sup:start_store/1 yourself. Producer-only services (no store) omit these and pay nothing.

%% Optional store-wiring callbacks (only if the service owns a store)
-export([store_id/0, data_dir/0, store_indexes/0]).

%% Atom store id. Data lands at <data_dir>/<store_id>/.
store_id() -> my_service_store.

data_dir() -> "/var/lib/hecate-my-service".

%% reckon-db secondary index declarations installed on the store. This is
%% how CCC payload indexes get declared — without it the store starts with
%% no secondary indexes and payload/hash queries find nothing.
store_indexes() ->
    [tags, event_type,
     {payload, <<"plate">>},                            %% single-field index
     {payload_hash, [<<"lot_id">>, <<"plate">>]}].      %% composite hash index

store_indexes/0 is itself optional: export it only when the store needs secondary indexes. Omit it (or return []) for a store with none.

Requires hecate_om >= 0.3.4. (0.3.3 introduced the callback but failed to export the helper it calls, crashing boot — use 0.3.4+.)

Scaffold a new service

# From the directory that will hold the new repository,
# typically ~/work/github.com/hecate-services:
scripts/scaffold-service.sh hecate-newservice "Does X over the mesh" 8484

That is a thin wrapper over rebar3 new hecate_service, and it exists so you say the name once. A service has two names: the repository, the container image and the name it answers to on the mesh are kebab-case, while the OTP application and every module prefix are snake_case because they are Erlang atoms. Mustache has no functions, so a template cannot derive one from the other. The generated eunit suite asserts the two agree modulo the separator, so a hand-rolled rebar3 new with a mismatched pair still fails on its first test run.

To use rebar3 new directly, install the templates once. rebar3 only finds custom templates under ~/.config/rebar3/templates, and an empty directory has no dependencies to carry them there:

scripts/install-templates.sh          # symlinks; --remove to undo
rebar3 new hecate_service repo=hecate-newservice name=hecate_newservice \
    desc="Does X over the mesh" org=your-org registry=ghcr.io health_port=8484

The scaffold is not house-specific. org and registry are variables, and nothing generated names our organisation, our registry, our deployment repository or our hosts. If you are building a hecate service for your own mesh, set those two and everything else follows. scaffold-service.sh defaults them to ours because that is who runs it most; HECATE_ORG and HECATE_REGISTRY override. A test generates a service as a stranger and fails if any of our own specifics survive.

Generates a repository that compiles, tests and deploys:

  • apps/<app>/src/ — the .app.src, _app.erl, _sup.erl and a _service.erl implementing the behaviour
  • apps/<app>/test/ — a suite asserting the contract's shape, the two names, and that the reported version is the application's own
  • rebar.config with a relx release, the prod profile and the elvis ruleset
  • config/sys.config.src and config/vm.args.src
  • Containerfile (multi-stage, macula's QUIC NIF built from source)
  • deploy/docker-compose.yml — the service's own run contract, not the deployed file; fleet placement lives in macula-demo
  • .github/workflows/lint-and-test and build-push to ghcr.io
  • scripts/health.sh, executable
  • README.md, CHANGELOG.md, LICENSE, .gitignore

It emits no TODO and no stub. What it generates is honestly complete and empty: the supervisor has no children, and the service announces no capability and requests no authority. Those are the correct answers for a service that does nothing yet, and each is asserted by a generated test, so filling one in is a deliberate act that breaks a test rather than a comment someone forgets.

The templates are exercised by hecate_service_template_SUITE, which generates a service for real and compiles it. The suite exists because the previous templates drifted unnoticed for months, and a template with no test is documentation that compiles.

One thing it cannot do for you, and it has bitten: the registry package may be created private, and the pull then fails on the host with a bare unauthorized that names nothing. Check it after the first build.

Deploying on the BEAM Campus fleet

Ours, and deliberately not part of the scaffold. deploy/docker-compose.yml in a generated service carries what the service knows about itself; the fleet's GitOps state lives in macula-demo and carries placement.

  1. Add a compose file under macula-demo/infrastructure/scripts/ with the node, the station seed, the realm, the secret file and the compose project name.
  2. Add a line to that node's reconcile.manifest.
  3. Seed the node's secret at ~/.hecate/secrets/, 0600. A manifest entry without it is a silent no-op that looks exactly like a successful deploy.

Health ports already bound across the fleet: 8450, 8471, 8481, 8482, 8483. Host networking makes a collision a silent bind failure.

Status

Working library — v0.9.0. The behaviour and all helpers are implemented (hecate_om_identity, hecate_om_capabilities, hecate_om_store, hecate_om_health), the boot path (hecate_om:boot/1 with auto store-wiring) is exercised by a Common Test suite (hecate_om_SUITE), and rebar3 new hecate_service generates a service that compiles, tests and deploys, guarded by a suite that generates one for real.

The behaviour surface has grown since the first cut: the store-wiring callbacks are store_id/0 + data_dir/0 (required together) plus optional store_indexes/0 (CCC secondary indexes), store_mode/0 (single | cluster), and store_integrity/0 (per-store HMAC event tamper-resistance). See the CHANGELOG for the evolution.

Known gap: the store-wiring callbacks are the part of the contract with no test of their own. hecate_om_SUITE boots a producer-only dummy service.

First consumers are onboarding: hecate-services/hecate-spartan links against the store-wiring path, and hecate-services/hecate-rag follows when the RAG daemon is extracted from hecate-app-rag. Not yet burned in under sustained production load.

License

Apache-2.0. See LICENSE.