Core Concepts
This page is a reference for Elara's core building blocks — what each one is, the fields that matter, and the rules the server actually enforces. It is not a walkthrough: for hands-on introductions see the Quickstart and the To-Do app tutorial.
Elara stores everything in a single bbolt
file and exposes it three ways: a Web UI, a ConnectRPC API (both on port
8080), and an etcd-compatible gRPC API (port 2379). The concepts below are
shared across all three surfaces.
Namespaces
A namespace is the top-level container for configs. It maps directly to the first path segment of an etcd key (see Configs), so every config lives in exactly one namespace.
Fields (internal/domain/namespace.go):
Name— canonical identifier, validated as a canonical name (used verbatim in etcd keys and RBAC scopes).DisplayName— optional human-friendly label.Description— optional free text.Locked— change-freeze flag (see below).ConfigCount,CreatedAt,UpdatedAt— bookkeeping.
Lifecycle operations (internal/usecase/namespace/): create, update, delete,
lock, unlock.
Locking. Locking a namespace sets Locked = true. While a namespace is
locked, every config write inside it — create, update, delete, both via the
Web/ConnectRPC API and the etcd gRPC API — is rejected with ErrNamespaceLocked.
Use it as a change freeze to protect a whole environment (e.g. prod) from
edits. A locked namespace also cannot be deleted; you must unlock it first.
Delete guard. A namespace can only be deleted when it is unlocked and empty. Deleting a namespace that still contains one or more configs fails with a validation error reporting the config count — Elara never cascades a namespace delete into its configs.
Configs
A config is a single key/value entry inside a namespace
(internal/domain/config.go).
Fields that matter:
Path— must start with/, must not contain//, and must not end with/(e.g./services/api.yaml).Content— the config body, stored normalized.Format—json,yaml, orother. Parsed viaParseFormat(ymlis accepted as an alias foryaml); auto-detected from the path extension (.json/.yaml/.yml, elseother) when not set. Format is immutable on update — you cannot change a config's format after creation.ContentHash— SHA-256 of the content, used to detect no-op writes.Version— per-config counter, starts at1and increments on each write.Revision/CreateRevision— the global revision at last modify (etcdmod_revision) and at first create.Metadata— arbitrary string map.Locked/NamespaceLocked— this config's own lock, and whether its namespace is locked.
etcd key mapping. A config is addressable over the etcd gRPC API as
/{namespace}{path}, where the path keeps its leading slash
(internal/handler/etcdv3/keyencoding.go):
namespace "prod" + path "/services/api.yaml" → /prod/services/api.yaml
namespace "default" + path "/foo.json" → /default/foo.json
The first path segment after the leading / is the namespace; everything from
the second / onward (leading slash included) is the config path.
History. Every write appends a revision-stamped entry to the history
bucket. You can list a config's history and read it as it existed at any past
revision (internal/usecase/config/service_history.go, GetKVAtRevision) —
this is what backs etcd point-in-time reads and watch-from-revision.
Config locking. Individual configs can be locked and unlocked
(internal/usecase/config/service_update.go). While a config is locked, any
attempt to update or delete it is rejected with ErrLocked — enforced in
storage, so it holds for both the UI and the etcd API (the etcd Put/
DeleteRange paths return the same error). Lock/unlock transitions are recorded
in a lock-history bucket. Over ConnectRPC a locked write surfaces as a
failed_precondition error. Locking a namespace has the same effect on all of
its configs (NamespaceLocked).
Schema Validation
Elara can validate config content against a JSON Schema attached to a
namespace (internal/domain/schema.go, internal/usecase/schema/).
A SchemaAttachment binds one JSON Schema to a (Namespace, PathPattern) pair.
The pattern is a glob (using github.com/gobwas/glob with / as separator),
so a single schema can cover many configs:
// attach to namespace "prod", pattern "/services/*.json"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["port"],
"properties": {
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
}
Which schema applies. When more than one attached pattern matches a config
path, the most specific pattern wins — specificity is scored by counting
wildcard characters (*, ?, [); fewer wildcards is more specific. On a tie,
the oldest attachment wins (findBestMatch). Only the single best match is
applied to a given config.
When it runs. Validation happens on every config create and update, before
the write. other-format configs are skipped (only json and yaml are
validated; YAML is re-marshaled through JSON before checking). Schemas are
compiled with github.com/santhosh-tekuri/jsonschema v6, which reads the draft
from the schema's $schema keyword (through draft 2020-12), and compiled
schemas are cached by content hash.
On failure. The write is rejected with a SchemaValidationError carrying a
list of violations, each {path, message, keyword}. Its message reads:
schema validation failed: N violation(s): /field: message [keyword]; ...
Over ConnectRPC this maps to an invalid_argument error whose message is the
string above, plus a structured SchemaValidationFailure error detail
containing every violation's path, message, and keyword
(internal/handler/v2/errors.go).
Bundles
A bundle is a portable snapshot of configs used for export/import
(internal/domain/bundle.go, internal/usecase/transfer/).
BundleConfig—{Path, Content, Format, Metadata}.NamespaceBundle— one namespace and its configs.AllBundle— every namespace (multi-namespace export/import).
Export produces JSON or YAML (encoding chosen per request), optionally
packaged as a ZIP for an all-namespaces export (one file per namespace under
namespaces/<name>.<ext> plus an index).
Import replays a bundle. When a config in the bundle already exists, the
onConflict mode decides the outcome (service_import.go):
| Mode | Behavior on an existing config |
|---|---|
SKIP (default, also used for UNSPECIFIED) |
Left untouched; counted as Skipped. |
OVERWRITE |
Existing config is updated (its current Version is preserved); counted as Updated. |
FAIL |
Recorded as an error ("config already exists"); counted as Failed. |
Configs that don't yet exist are always created. Imports can target an explicit
namespace (overriding the bundle's own namespace field) or infer scope from the
bundle; a multi-namespace AllBundle import requires Transfer:write on *.
Preview is dry-run, not a diff. The import preview runs with dryRun = true
and returns an ImportReport — aggregate counts of Created / Updated /
Skipped / Failed plus a per-path error list. It does not show a
per-config content diff: an OVERWRITE preview reports a config as Updated
without telling you what would change. Treat the preview as a summary of how
many entries fall into each bucket, not as a review of the actual changes.
Clients & Observability
"Clients" in Elara means consumers of the etcd-compatible gRPC API — the services reading configs over the etcd wire protocol. UI and ConnectRPC users are not clients.
When a gRPC connection is established, Elara captures a ConnectionInfo from
gRPC metadata (internal/transport/grpc/stats_handler.go,
internal/domain/client.go):
UserAgent— the standard gRPCuser-agent.ClientName/ClientVersion— from thex-client-nameandx-client-versionheaders.K8sNamespace/K8sPod/K8sNode— fromx-client-k8s-namespace,x-client-k8s-pod,x-client-k8s-node.InstanceID— fromx-client-instance-id.PeerAddress— the connecting address.
Those x-client-* headers are supplied by the client (typically wired from the
Kubernetes downward API); when absent, the fields are simply empty.
The in-memory monitor registry (internal/service/monitor/registry.go) tracks,
per connected client: request counts by RPC method, error count, last-activity
time, and the list of active watches (each with its key range, start
revision, and flags). It also keeps a small ring buffer of recent per-RPC events
and publishes changes so the UI can update live.
- The Active view lists clients that are currently connected
(
DisconnectedAt == nil). - The History view shows clients that have since disconnected.
All of this state is in-memory only — it is not persisted and is lost on restart.
Webhooks
A webhook delivers config-change notifications to an external HTTP endpoint
(internal/domain/webhook.go, internal/usecase/webhook/).
Fields:
URL— target endpoint (must behttp/https).Events— any ofcreated,updated,deleted(at least one required).NamespaceFilter— if set, only events in that namespace fire.PathPrefix— if set, only configs whose path is at or below that prefix fire (boundary-aware:/svcmatches/svcand/svc/...but not/svcold).Secret— optional HMAC signing key.Enabled— disabled webhooks match nothing.
Delivery & signing. The dispatcher POSTs a JSON body with
Content-Type: application/json. When a Secret is set, it computes an
HMAC-SHA256 over the body and sends it in the X-Elara-Signature header, formatted
as sha256=<hex> (internal/transport/webhook/dispatcher.go) — verify it on
your receiver to authenticate the payload.
Each attempt is recorded as a DeliveryAttempt (AttemptNumber, StatusCode,
LatencyMS, Error, Success, Timestamp), giving a delivery history with
retries and latency.
RBAC
Elara uses Casbin RBAC with one hard invariant: users get permissions only
through group membership — there is no way to grant a permission directly to a
user. For the rationale and the full model, see
ADR 0002: Groups-only RBAC. This section covers
the practical mechanics (internal/domain/rbac.go).
Permissions are tuples, not canned roles. A group holds a set of
Permission{Object, Action, Domain} grants, assigned explicitly through the API
or UI. There is no fixed "reader gets X, writer gets Y" expansion table on the
web side — you compose the exact objects/actions/scopes a group needs.
- Objects:
namespace,token,client,dashboard,user,group,policy,webhook, and*(all). Note thatnamespacecovers all namespace-scoped content — configs, schemas, and export/import included; there is no separate object for those. - Actions:
read,write,create,delete, and*(all).writeimpliesread(you can't edit what you can't read), and*matches any action. - Domain (scope): the scope a grant applies to — a namespace
(
namespace:<name>), a group (group:<name>), or*(global / every namespace).
Namespace-scoped vs global. A grant like namespace / write / namespace:prod
lets the group write configs in prod only. The same grant with domain *
applies to every namespace. Group membership itself is always global.
The role names. admin, writer, and reader exist as constants. admin
is the wildcard role: the seeded system superadmin group is granted
* / * / * (all objects, all actions, all namespaces) as a break-glass policy.
reader and writer are used primarily as etcd service-token roles — a
token carries a role plus a namespace list, where reader is read-only and
writer additionally permits writes within its namespaces
(internal/handler/etcdv3/access.go). (Service tokens are independent
credentials — see the notes on tokens in the project overview.)
Global Revision
Elara maintains a single monotonic revision counter that increments on every
config write across all namespaces — the etcd mod_revision model. Each write
stamps the config's Revision (and, on create, CreateRevision) with the
counter's value and records a history entry at that revision.
This counter is what enables point-in-time reads and etcd
watch-from-revision, and it surfaces on the dashboard as the Global Revision
KPI (internal/usecase/dashboard/service_stats.go, CurrentRevision). Because
it is global, its value reflects total write activity across the whole store,
not any single namespace.