Embedding kata in Go

July 22, 2026 ยท View on GitHub

The module root, go.kenn.io/kata, exposes kata as a listener-free application service. Use it when a Go application should own the HTTP server and process lifecycle instead of supervising a separate kata daemon process. The mounted handler serves the same HTTP API used by the CLI and TUI.

The host application remains responsible for the listener, TLS, signal handling, and HTTP server shutdown. A kata.Service owns its storage handle and the federation, GitHub sync, and timed-claim background workers associated with that handle.

Minimal lifecycle

Construct the service, start its workers with Run, and use its Handler in a caller-owned server:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"go.kenn.io/kata"
)

func main() {
	ctx, stop := signal.NotifyContext(
		context.Background(), syscall.SIGINT, syscall.SIGTERM,
	)
	defer stop()

	service, err := kata.New(ctx, kata.Config{
		DSN: "/var/lib/example-app/kata.db",
		Auth: kata.AuthConfig{
			Token: os.Getenv("EXAMPLE_KATA_TOKEN"),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	server := &http.Server{
		Addr:              "127.0.0.1:8080",
		Handler:           service.Handler(),
		ReadHeaderTimeout: 5 * time.Second,
	}
	runDone := make(chan error, 1)
	serveDone := make(chan error, 1)
	go func() { runDone <- service.Run(ctx) }()
	go func() { serveDone <- server.ListenAndServe() }()

	var runErr, serveErr error
	var runFinished, serveFinished bool
	select {
	case runErr = <-runDone:
		runFinished = true
	case serveErr = <-serveDone:
		serveFinished = true
	case <-ctx.Done():
	}
	stop()

	shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
	shutdownErr := server.Shutdown(shutdownCtx)
	cancelShutdown()
	if !runFinished {
		runErr = <-runDone
	}
	if !serveFinished {
		serveErr = <-serveDone
	}
	if errors.Is(serveErr, http.ErrServerClosed) {
		serveErr = nil
	}
	if err := errors.Join(runErr, serveErr, shutdownErr, service.Close()); err != nil {
		log.Fatal(err)
	}
}

Run does not open a listener. It blocks while federation, scheduled GitHub sync, and timed-claim workers are active, then returns when its context is canceled or Close begins, and it may return an error from any background worker. Treat any Run return as service termination: stop accepting HTTP traffic, cancel the shared context, and inspect the error after both the workers and server stop. Only one Run call may be active for a service. Close cancels active work and requests, waits for them to stop, and closes the owned storage handle. It is safe to call more than once.

Authentication is explicit

kata.New requires exactly one authentication policy:

  • Set Auth.Token to make kata enforce a bearer token on its protected HTTP routes. The host remains responsible for transport security.
  • Set Auth.TrustCallerAuthentication to true only when trusted middleware already authenticates every request before it reaches service.Handler(). Never expose that handler directly on an untrusted listener.
  • Set Config.Access when the host owns both identity and per-operation authorization. The host middleware attaches an authenticated kata.Principal in process with kata.WithPrincipal; Kata never accepts that principal from a network header.

Construction fails when no policy is selected or when policies are combined. The explicit choice prevents an embedded service from accidentally inheriting the standalone daemon's local-user trust boundary.

The lifecycle example deliberately binds to loopback. To accept remote clients, use server.ListenAndServeTLS(certFile, keyFile) with a valid certificate or mount the handler behind a TLS-terminating reverse proxy. Never send the bearer token over plaintext non-loopback HTTP.

Host-owned access

AccessController is the fine-grained embedding seam. Kata calls it after a route matches and before protected data is returned or changed. The request contains the opaque authenticated subject, the actor snapshot used for new event and projection rows, and the matched operation ID, method, path template, path parameters, and Kata's operation policy. The policy contains a stable domain kind, the required product-neutral read, write, manage, or federate capability, and explicit mutation and long-lived-response flags. Hosts map those capability classes to their own roles; they do not need to infer policy from operation names or HTTP verbs. Kata's registration test fails if a new route has no policy, and host-access mode fails closed if metadata is unavailable.

Operation.ProjectIDs, ProjectUIDs, and AllProjects carry the validated effective project scope. Cross-project operations include both projects; omitting the project filter from the event stream sets AllProjects instead of silently broadening an empty scope. Body- and query-selected projects are decoded and validated before this decision. When an operation discovers another project while resolving a link, UID, or graph, Kata asks again with the expanded scope before returning or changing protected data. Controllers should therefore make retry-safe decisions from the complete request rather than treating a call as a one-time notification. Global operations set AllProjects explicitly. Operations such as purge, close, imports, ready selection, UID-prefix lookup, event feeds, close audits, and project digests also require AllProjects because their results or side effects can depend on relationships outside the project named in the URL. Parent-link changes and deletion by a global link ID use the same conservative scope. The same applies to tolerant relationship removals, whose missing-target no-op must not reveal whether a target belongs to a denied project. The request deliberately contains no application roles or tenant model.

For lease operations, Kata derives an opaque holder key from the stable Principal.Subject; a request-provided holder cannot select or impersonate another mounted caller. Subject bytes are compared exactly after rejecting empty or whitespace-only values, so the embedding host must provide one canonical subject representation. Subject ownership remains distinct when a spoke forwards a lease through its shared federation identity. Existing non-host lease clients keep their established holder and client-kind behavior; mounting a service does not rewrite their persisted ownership tuples. Host provenance is carried separately from those caller-visible strings, so reserved-looking holder or client-kind text cannot opt an ordinary client into mounted identity handling. Principal.Actor remains the human-readable audit snapshot for replica setup and other mutations.

An embedding host typically mounts the service behind its ordinary session or credential middleware:

service, err := kata.New(ctx, kata.Config{
	DSN:    "/var/lib/example-app/kata.db",
	Access: applicationAccessController,
})
if err != nil {
	return err
}

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	principal, err := authenticateApplicationRequest(r)
	if err != nil {
		http.Error(w, "authentication required", http.StatusUnauthorized)
		return
	}
	ctx := kata.WithPrincipal(r.Context(), kata.Principal{
		Subject: principal.StableID,
		Actor:   principal.DisplayName,
	})
	service.Handler().ServeHTTP(w, r.WithContext(ctx))
})

The controller returns kata.ErrAccessDenied for a rejected operation. Kata answers with a generic not-found envelope so the response does not confirm that a protected resource exists. Other controller failures return a bounded service-unavailable envelope without exposing the underlying error.

Long-lived operations require an AccessLease in the successful decision. Kata revalidates that lease before each event or heartbeat; a failed revalidation closes the stream before more protected data is written. Bounded requests may return a decision with no lease.

Return a TransactionFence with every successful decision. Kata invokes it only when handling the operation begins a writable SQLite or PostgreSQL transaction, before the first domain write. This also covers read operations that perform transactional maintenance, while explicitly read-only snapshots remain outside the mutation fence. SQL locks acquired by the callback are therefore held through the domain commit or rollback; the callback must not commit or roll back the supplied transaction itself. Returning ErrAccessDenied rolls the mutation back and produces the same generic not-found response as an initial authorization denial. Other failures roll back and make only the mounted service temporarily unavailable. A missing fence fails closed before writing.

Serializable transactions may retry, so a fence must be safe to invoke once per transaction attempt. The transaction exposes only ExecContext and QueryRowContext, which is enough to call a fixed host-owned validation function without exposing Kata's internal storage packages.

When Config.Access is set, also configure WorkerTransactionFence. Kata applies it to every writable transaction started by the federation, GitHub sync, and timed-claim workers. A rejection rolls the transaction back, cancels all service workers, and is returned by Run; it cannot be reduced to a logged retry while stale authority remains active.

The host-supplied actor always replaces an actor in request JSON. This keeps audit attribution tied to the authenticated principal rather than caller input. Auth.TrustCallerAuthentication preserves the older all-or-nothing trusted middleware mode; use Config.Access when projects or operations need distinct authorization decisions.

Federation transport and lease routes may authenticate with Kata-managed, project-scoped bearer credentials. When such a request has no in-process host principal, Kata validates the scoped credential in the route instead of calling the ordinary AccessController. The outer server must preserve the Authorization header on those routes. A request without either an in-process principal or a scoped bearer credential remains unauthenticated.

Mounting applications that have additional credential-lifecycle rules can set Config.FederationAccess. Kata calls that controller only after its own token, project, and capability checks succeed. The request contains the retained enrollment without its token hash, the resolved project, the exact pull, push, or claim capability, and the stable operation ID. Returning ErrAccessDenied makes the otherwise valid credential unusable without disclosing which outside condition changed. Standalone behavior is unchanged when the controller is absent.

Federation mutations require the controller to return a TransactionFence. Kata runs it inside the same storage transaction as event ingest or lease state, so a concurrent revocation is ordered with the protected write. A missing fence fails closed. Federation metadata and lease-status requests also require a fence because they may refresh baselines, expire claims, or update a spoke's cached lease state even though their HTTP method is GET. Tokenless lease-status reads remain available in explicitly trusted-caller mode; once a bearer credential is supplied, Kata authenticates and authorizes that credential. When a request carries both an in-process principal and a scoped federation credential, the ordinary host-access fence runs first and the federation fence runs only after it succeeds; neither authorization layer replaces the other. The callback follows the same retry and transaction rules as the ordinary host-access fence described above.

Restricted embedding profile

Use EmbeddingProfileRestricted when the mounting application owns project lifecycle, API-token administration, federation setup, and external issue-sync configuration:

service, err := kata.New(ctx, kata.Config{
	DSN:     "/var/lib/example-app/kata.db",
	Access:  applicationAccessController,
	Profile: kata.EmbeddingProfileRestricted,
})

Restricted mode returns a generic not-found response for native project mutations, token administration, federation administration, and issue-sync administration. Rejection happens before host authorization or request-body processing. Project and task reads, ordinary task changes, event streams, and Kata-authenticated federation transport remain available under their normal access checks. The zero-value profile preserves the full standalone-compatible HTTP API.

Use the in-process project lifecycle methods below instead of enabling native project administration in restricted mode. Later profile revisions may add more host-owned application methods without changing standalone defaults.

Host-managed projects

Applications that keep their own project catalog can establish one stable Kata project without calling the HTTP API internally:

result, err := service.EnsureProject(ctx, kata.ProjectSpec{
	UID:  "01HZNQ7VFPK1XGD8R5MABCD4EX",
	Name: "example-host-project",
})

EnsureProject is idempotent across processes. An exact UID-and-name match returns the existing numeric identity and history; a reused UID or name that points at a different project returns kata.ErrProjectConflict. The caller can therefore retry after an interrupted catalog update without creating a second project. Archived projects are returned as kata.ProjectArchived and are not silently reactivated.

ArchiveProject retains that same UID, numeric identity, tasks, and events while removing the project from ordinary active reads. It is idempotent and requires an actor for the retained event history. These methods are in-process application methods: they do not authenticate a network caller and should be invoked only after the host has authorized its own catalog lifecycle change.

Host-managed federation enrollments

Restricted embedding keeps Kata's scoped federation transport routes but removes its native enrollment-administration routes. After applying its own authorization policy, a host can issue a project-scoped transport credential directly through the service:

created, err := service.CreateFederationEnrollment(ctx, kata.FederationEnrollmentSpec{
	ProjectUID:       "01HZNQ7VFPK1XGD8R5MABCD4EX",
	SpokeInstanceUID: "01HZNQ7VFPK1XGD8R5MABCD4EA",
	Capabilities:     "pull,push,claim",
	Actor:            "Example Operator",
})

Creation atomically enables hub federation for the active project when necessary. Kata generates the bearer secret, stores only its hash, and returns the plaintext in created.Token exactly once. ListFederationEnrollments returns retained history for one stable project UID without either form of the secret. RevokeFederationEnrollment requires both the project UID and enrollment ID, so an ID from another project is not accepted; repeating an exact revocation is harmless. History remains listable and credentials remain revocable after a project is archived, but archived projects cannot receive new enrollments.

Like the project lifecycle methods, these are trusted in-process application methods rather than network authentication boundaries. The embedding host must authorize create, list, and revoke operations before calling them.

Storage and PostgreSQL policy

Config.DSN is required and accepts a bare SQLite path, a sqlite:// URL, or a postgres:// / postgresql:// URL. A PostgreSQL-backed service can make schema handling explicit:

service, err := kata.New(ctx, kata.Config{
	DSN: os.Getenv("EXAMPLE_KATA_DSN"),
	Postgres: kata.PostgresConfig{
		Schema:      "kata",
		SchemaMode:  kata.PostgresSchemaValidate,
		SchemaOwner: "kata_schema_owner",
	},
	Auth: kata.AuthConfig{Token: os.Getenv("EXAMPLE_KATA_TOKEN")},
})

PostgresSchemaBootstrap installs missing migrations before serving. PostgresSchemaValidate performs no schema installation and requires an already compatible schema, which lets production applications separate schema preparation from runtime serving. The standalone PostgreSQL operator ceremony describes the same role, migration, TLS, and rollback requirements.

Service-scoped credentials

GitHub sync credentials are supplied through Config.GitHubSync. Federation credentials are isolated per service through Config.FederationCredentials. When that field is nil, kata uses a service-owned in-memory credential store; credentials then disappear when the process exits. Applications that need federation enrollment to survive restarts should implement kata.FederationCredentialStore with durable, concurrency-safe storage.

The embedded service does not read a listener address, install signal handlers, or take over the host application's logger. Pass a *slog.Logger in Config.Logger when kata should use an application-specific logger.