Files
zeekayandHanzo Dev 9259e6f01c tasks: the edge writes identity headers, it does not mint them
Minting is issuing authority. IAM mints — it signs the token, and that
signature is where the authority is created. tasksd verifies one and
restates it as headers a transport that cannot carry a token can read.
Nothing here creates authority, so naming both operations "mint" put the
issuer's job and this service's under one word, and a word that covers
two operations is a word that stops distinguishing them.

Every place that named the header write "mint" now names it "write",
matching the leaf it already cites: hanzoai/authz names its header list
"once by the party that WRITES their values". The four ctx accessors said
"minted" for a value that is neither issued nor a header — they return
what the validated claims RESOLVED to, which is the word the org scoping
already used for Claims.EffectiveOrg.

"mint" stays where it is the right word: the test helper signing a JWT,
the `owner` claim IAM stamps at sign time, the HMAC task tokens dispatch
issues, and money mint/burn in CONTRACT.md.

LLM.md also cited edge.Inject, which no longer exists. The call is
edge.Apply — the name it took when the four readers collapsed onto one
leaf, precisely because writing headers is not injecting authority.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 16:02:10 -07:00

10 KiB

Hanzo Tasks — Canonical Contract

The single durable async substrate for every Hanzo Go service.

This document is law. If your service needs retries, durability, scheduling, cron, or any async work that must survive a process restart, you use github.com/hanzoai/tasks. There is no second async system.

1. When to use tasks vs sync

Sync HTTP handler (no tasks):

  • Total work fits in <100ms.
  • Caller blocks for the response and the result is the whole point of the call.
  • Single round-trip with no external I/O that can flake (OTP code generation, JWT issue, field validation, in-memory CLOB match, JWKS lookup).
  • Failure is the response — the caller decides what to do.

Tasks (durable workflow):

  • Any side-effect that must survive a pod restart or panic.
  • Anything with retry policy (webhook delivery, mint/burn, settlement, notification send, third-party API fan-out).
  • Anything scheduled (cron, delay, send_at, daily/quarterly sweeps).
  • Anything that fans out across multiple steps where a partial completion is worse than no completion (saga-style flows).
  • Long jobs (>5s) where the HTTP caller should not block.

Rule of thumb: if you find yourself writing go func() { ... }() to fire work in the background, you are writing a bug. Use tasks.

2. Task definition shape

Every workflow is named, namespaced, and has a typed input + output.

// pkg/tasks/<feature>.go in your service.
package <feature>tasks

import (
    "time"

    "github.com/hanzoai/tasks/pkg/sdk/workflow"
)

// Input is the workflow + activity payload. JSON-serializable, no
// pointers, no interfaces.
type Input struct {
    ID         string         `json:"id"`
    TenantSlug string         `json:"tenant_slug"`
    // … domain fields …
}

// Output is the workflow return shape.
type Output struct {
    ID     string `json:"id"`
    Status string `json:"status"`
    Error  string `json:"error,omitempty"`
}

// FeatureWorkflow is the durable entry point. Must be deterministic on
// its inputs — all I/O happens inside activities.
func FeatureWorkflow(ctx workflow.Context, in Input) (Output, error) {
    actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
        StartToCloseTimeout: 60 * time.Second,
        RetryPolicy: &workflow.RetryPolicy{
            InitialInterval:    5 * time.Second,
            BackoffCoefficient: 2.0,
            MaximumInterval:    2 * time.Minute,
            MaximumAttempts:    5,
        },
    })
    var out Output
    if err := workflow.ExecuteActivity(actCtx, "Deliver", in).Get(actCtx, &out); err != nil {
        return Output{ID: in.ID, Status: "failed", Error: err.Error()}, err
    }
    return out, nil
}

Defaults — apply unless your workflow explicitly contradicts them:

Field Default When to change
StartToCloseTimeout 60s I/O heavy >60s; never above 10m
InitialInterval 5s Fast-retry only if upstream <1s
BackoffCoefficient 2.0 Never lower (thundering herd)
MaximumInterval 2m Long-tail jobs may bump to 30m
MaximumAttempts 5 Idempotent ops can go to 10

Idempotency is the activity's responsibility. The tasks server replays on retry; your activity must detect "already done" and return the prior result.

3. Worker registration pattern

One worker per service, started during boot, drained on shutdown.

// in your service's main.go boot path
import (
    tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
    tasksworker "github.com/hanzoai/tasks/pkg/sdk/worker"
)

cli, err := tasksclient.Dial(tasksclient.Options{
    Address:   os.Getenv("TASKS_ADDR"),     // "tasks.hanzo.svc:9999" or "/run/tasks.sock"
    Namespace: tenantNamespace(orgID),       // see §6
})
if err != nil {
    return fmt.Errorf("tasks dial: %w", err)
}

wk := tasksworker.New(cli, "<service>-default", tasksworker.Options{
    Logger: logger,
})
wk.RegisterWorkflow(FeatureWorkflow)
wk.RegisterActivity(activities.Deliver)
if err := wk.Start(); err != nil {
    return fmt.Errorf("tasks worker start: %w", err)
}
defer wk.Stop()

Task queue name: <service>-<purpose> (e.g. notify-send, bd-webhooks, ta-chain, ats-settlement). One queue per workflow family. Do not multiplex unrelated workflows on the same queue.

4. Client API

The sync caller's interaction with tasks is exactly two calls.

// Enqueue — returns immediately with task id.
run, err := cli.ExecuteWorkflow(ctx, tasksclient.StartWorkflowOptions{
    ID:        "<feature>-" + in.ID, // deterministic ID = dedup key
    TaskQueue: "<service>-<purpose>",
}, "FeatureWorkflow", in)
if err != nil {
    return "", fmt.Errorf("tasks enqueue: %w", err)
}
taskID := run.GetID()

// Optional wait for completion (sync-over-async).
var out Output
if err := run.Get(ctx, &out); err != nil {
    return out, err
}

ExecuteWorkflow is idempotent on the workflow ID. Re-submitting the same ID with the workflow still running returns the existing handle. Use the business key (message_id, withdrawal_id, intent_id) as the workflow ID — that is your idempotency token.

5. ZAP transport

Production: ZAP only. tasksd listens on TCP/9999 by default -- or on a unix socket when --zap is given a path, which is the right shape when caller and engine share a host; the SDK dials directly. No HTTP, no JSON envelope on the hot path, no gRPC.

Local dev: TASKS_ADDR="" switches the SDK to in-process mode (no durability, used for tests and dev loops). Production must set TASKS_ADDR=tasks.hanzo.svc:9999 (or the per-cluster service name).

Auth flows via the same JWT the caller already holds. The SDK attaches Authorization: Bearer <jwt> from tasksclient.Options.AuthToken or from the context's outbound header bag. tasksd validates against IAM JWKS (see hanzoai/tasks LLM.md §"Auth: IAM only").

6. Per-tenant namespace isolation

Namespaces map 1:1 to IAM orgs. This is non-negotiable.

Layer Value
IAM org mlc
Tasks namespace mlc
SQLite store data/mlc.db

Workers dial with Namespace: <orgSlug>. All schedules, workflows, and visibility queries are namespace-scoped at the server. An org-a worker cannot see org-b workflows even if both share a binary. The strip + write identity middleware in tasksd enforces this on every RPC.

Multi-tenant services hold one client + worker per active org. Onboarding a new org allocates the namespace lazily on first ExecuteWorkflow.

Single-tenant services (notify when serving Hanzo only) may run a single default namespace; the moment they serve a second tenant, switch to per-org namespacing — no retrofitting allowed in production.

7. Failure modes

  • Activity error returned: tasks server retries per the workflow's RetryPolicy. After MaximumAttempts, the workflow run is marked failed and the result is queryable via cli.DescribeWorkflowExecution.
  • Activity panic: caught by the SDK; counted as an attempt; retried.
  • Worker process killed mid-activity: the heartbeat times out; the task is rescheduled on another worker. Activities MUST be idempotent.
  • Workflow exceeds WorkflowExecutionTimeout: terminated; downstream saga must reconcile or fire a compensating workflow.
  • Server unreachable on enqueue: ExecuteWorkflow returns an error. The caller decides — usually: persist the intent to local storage and resubmit on a recovery cron, never silently drop.

Dead-letter behavior: there is no separate DLQ collection. Failed runs stay in the visibility store with status=failed and are queryable. The operator surface (CRD LiquidTasks / HanzoTasks .status) surfaces counts of failed_24h per namespace per queue.

Alerting: tasks_workflow_runs_failed_total{namespace,queue} Prometheus metric → alerts at >0.5% failure ratio over 5m or any 10 consecutive failures of the same workflow ID. The alert lands in the owning team's PagerDuty service via Hanzo Insights.

8. Observability

UI: tasks.hanzo.ai (hanzoai/tasks Web UI). Filter by namespace, task queue, status, time range. Inspect input/output/history for any run.

CLI:

# describe a stuck workflow
tasksctl workflow describe --namespace <org> <workflow-id>

# replay events for debugging
tasksctl workflow history --namespace <org> <workflow-id> --json

# queue depth (per-queue backlog)
tasksctl queue stats --namespace <org> <queue-name>

# force-fail a runaway workflow
tasksctl workflow terminate --namespace <org> <workflow-id> --reason <text>

Metrics (Prometheus, scraped from tasksd /metrics):

Metric Labels
tasks_workflow_runs_started_total namespace, workflow_type
tasks_workflow_runs_completed_total namespace, workflow_type, status
tasks_workflow_runs_failed_total namespace, workflow_type
tasks_activity_attempts_total namespace, activity_type, outcome
tasks_queue_backlog namespace, queue
tasks_workflow_run_duration_seconds namespace, workflow_type (histogram)

Traces: every workflow + activity emits an OTEL span. Span links connect the originating HTTP handler → workflow start → each activity attempt.

Logs: workflow + activity logs are structured slog records with workflow_id, run_id, namespace, task_queue, attempt. They land in the cluster's logging backend (Loki, in our case).

9. What this replaces

This contract supersedes:

  • Per-service pkg/taskqueue/ and pkg/tasks/ wrappers that wrap a fork of the SDK with HTTP fallbacks. The wrappers will be removed once direct callers migrate to github.com/hanzoai/tasks/pkg/sdk.
  • go.temporal.io/sdk/* imports anywhere outside this repo. Any service that still imports the legacy SDK gets migrated in the dispatched follow-up work; new code must not import it.
  • Ad-hoc time.NewTicker + go func() for retries, cron, or scheduled send. If you wrote one of those, you owe a tasks workflow.

10. Versioning

The Hanzo Tasks module pins to v1.x.x forever per the global Go package policy (no v2+ semver-import-versioning). New features land as minor bumps; fixes as patches. See LLM.md §"Versioning policy".