# @hasna/events

Shared event envelopes, local channels, replay, and delivery transports for Hasna open-source apps.

This package is local-first. By default it stores JSON files under `~/.hasna/events`:

- `channels.json`
- `events.json`
- `deliveries.json`

The data home is resolved by the in-package resolver, in this order:

1. The CLI `--dir` flag (highest precedence);
2. the exact-app `HASNA_EVENTS_DIR` override, then the legacy
   `HASNA_EVENTS_HOME` fallback;
3. the in-package-resolved XDG data home (`HASNA_DATA_HOME`/`~/.local/share/hasna/events`),
   adopted once `HASNA_DATA_HOME` is set or the store has been physically migrated there
   (`events.json` exists at that home);
4. the legacy `~/.hasna/events` default.

Nothing moves on its own: the package keeps reading and writing whichever home is
effective, and the legacy `~/.hasna/events` stays effective until an operator opts into
the XDG layout or the store is migrated to it.

## Storage Runtime Contract

The default compatibility runtime is local JSON files. It does not use local
SQLite, remote Postgres, S3, AWS infrastructure, or live cloud mutation. Apps
that require cross-process idempotency and restart-safe webhook delivery should
use the opt-in durable spool and SQLite broker described below.

```ts
import { getEventsStatus } from "@hasna/events";

const status = await getEventsStatus();

console.log(status.storage);
// {
//   mode: "local-files",
//   localFiles: true,
//   localSqlite: false,
//   remote: false,
//   postgres: false,
//   s3: false,
//   aws: false,
//   idempotency: "best-effort-local",
//   replayCursors: true
// }
```

Cloud-backed stores should implement the same `EventsStore` interface and, for
durable event-bus use, the optional `appendEventOnce` and `listEventsPage`
methods. `appendEventOnce` is the storage-layer hook for atomic idempotency by
`id` or `dedupeKey`, such as a Postgres unique constraint or equivalent
provider guarantee. `listEventsPage` returns an opaque cursor page for bounded
replay. The local JSON store implements these hooks for deterministic local
tests, but its idempotency is best-effort local file behavior, not a
cross-process database lock.

Remote Postgres/S3/AWS adapters should keep credentials and infrastructure
provisioning outside this package configuration, report their storage mode in
`status.storage`, and avoid emitting live external deliveries during store
tests. Creating buckets, databases, secrets, migrations, or production data
changes is an explicit deployment/approval step, not part of this local runtime.

## Install

```bash
bun add @hasna/events
```

The core CLI and durable SQLite broker require Bun 1.0 or newer. The isolated
`@hasna/events/durable-spool` producer export supports Node 20 or newer.

## Event Envelope

All apps should emit the same stable envelope:

```ts
import { EventsClient } from "@hasna/events";

const events = new EventsClient();

await events.emit({
  id: "optional-stable-id",
  source: "tickets",
  type: "ticket.created",
  time: new Date(),
  subject: "ticket:123",
  severity: "notice",
  data: { ticketId: 123 },
  message: "Ticket created",
  dedupeKey: "tickets:ticket:123:created",
  schemaVersion: "1.0",
  metadata: { tenant: "acme" },
});
```

Only `source` and `type` are required inputs. `id` defaults to a UUID, `time`
to the current ISO timestamp, `severity` to `info`, `data` and `metadata` to
empty objects, and `schemaVersion` to `1.0`. `subject`, `message`, and
`dedupeKey` are optional.

`source` should be the emitting app or bounded context. `type` should use dot
notation such as `ticket.created`, `repo.synced`, or `check.failed`. Emission
deduplicates by either an explicit `id` or `dedupeKey` by default; a duplicate
is neither stored nor delivered again. Library callers can pass
`{ dedupe: false }` to store a duplicate intentionally.

## Package Entry Points

The root export includes the client, types, storage, filtering, signing,
transports, and catalog APIs. Focused entry points are also available:

- `@hasna/events/storage`
- `@hasna/events/signing`
- `@hasna/events/filter`
- `@hasna/events/transports`
- `@hasna/events/catalog`
- `@hasna/events/commander`
- `@hasna/events/cli`

## Typed Event Catalog (Distribution Events)

`@hasna/events/catalog` binds well-known envelope `type` strings to the
`@hasna/contracts` schema ids their `data` payloads mirror, and provides an
OPT-IN emit-time validator hook.

Distribution event types (`DISTRIBUTION_EVENT_TYPES`):

| Event type | Contracts schema (`data` mirror) |
| --- | --- |
| `release.published` | `hasna.release.v1` |
| `release.rollout.started` | `hasna.rollout_record.v1` |
| `release.rollout.completed` | `hasna.rollout_record.v1` |
| `release.rollout.failed` | `hasna.rollout_record.v1` |
| `app.installed` | `hasna.rollout_record.v1` |
| `announcement.sent` | `hasna.announcement.v1` |
| `feedback.created` | `hasna.feedback.v1` |
| `feedback.triaged` | `hasna.feedback.v1` |

```ts
import { EventsClient, EventTypeCatalog, registerDistributionEventTypes } from "@hasna/events";

const catalog = registerDistributionEventTypes(new EventTypeCatalog());
const client = new EventsClient({ catalog, validateCatalogTypes: true });

// Registered type with an invalid payload throws EventValidationError
// BEFORE the event is stored or delivered.
await client.emit({
  source: "publish",
  type: "release.published",
  data: { appId: "todos" },
});
```

Validation is fully backward compatible:

- It is OFF by default (`validateCatalogTypes` defaults to `false`).
- Unregistered/free-form event types ALWAYS pass, even when validation is on.
- A per-emit `validate` option overrides the client setting in both directions.

Payload types (`ReleasePublishedData`, `RolloutData`, `AppInstalledData`,
`AnnouncementSentData`, `FeedbackCreatedData`, `FeedbackTriagedData`) are
dependency-free structural mirrors of the contracts schemas; this package does
not depend on `@hasna/contracts` at runtime.

## OpenAutomations Trigger Ingress

`@hasna/events` is trigger ingress for OpenAutomations. It records and delivers
event envelopes, but it does not own durable automation runs, action queues,
approvals, DLQ state, or replay decisions. `@hasna/automations` consumes the
same envelope shape and materializes matching events into durable automation
runs.

For automation-triggered events:

- set `source` to the emitting app or bounded context
- set `type` with dot notation, such as `ticket.created`
- set `subject` when the event describes one stable domain object
- set `dedupeKey` when the producer has a stable business identity
- keep `id` stable for the specific emitted envelope
- put only serializable trigger data in `data`
- keep secrets out of `data` and `metadata`; pass secret references instead

OpenAutomations derives idempotency from `dedupeKey` first and falls back to
`id` when no dedupe key is present. Replaying events through `events events
replay` re-delivers envelopes; OpenAutomations is still responsible for deciding
whether that delivery creates a new run, returns the existing idempotent run, or
creates an explicit replay request.

## OpenLoops Task Notifications

`@hasna/events` is also notification ingress for OpenLoops task-created routes.
It delivers `todos` envelopes to configured channels, but it does not import
OpenLoops, create workflow invocations, own admission queue state, run agents,
or decide worker retry/backpressure policy. OpenLoops is the consumer that
handles an envelope, dedupes/upserts a work item, admits it when capacity is
available, and records workflow run manifests under `.hasna/loops/runs`.

Replay remains delivery-only. Replaying a `todos.task.created` or
`task.created` envelope sends the event to matching channels again; OpenLoops
decides whether that replay is ignored as an already-admitted task, resumes
existing work, or creates an explicit replay work item.

## Channels And Filters

Channels are reusable notification routes. They can be enabled or disabled,
filtered by source/type/subject/severity, and configured with
transport-specific settings.

```ts
await events.addChannel({
  id: "ops-webhook",
  enabled: true,
  transport: "webhook",
  filters: [{ type: "ticket.*", severity: ["warning", "error", "critical"] }],
  webhook: {
    url: "https://example.com/channels/hasna",
    secret: process.env.HASNA_WEBHOOK_SECRET,
  },
  retry: {
    maxAttempts: 3,
    backoffMs: 500,
    multiplier: 2,
  },
  redact: {
    paths: ["data.token", "metadata.authorization"],
  },
});
```

All matchers inside one filter are ANDed. Multiple filters on a channel are
ORed. A channel with no filters accepts every event, while a disabled channel
accepts none. String matchers support `*` and `**`; `data` and `metadata`
matchers also support nested or literal dotted keys, primitive array-member
matching, typed values, and negative matchers.

## Webhook Transport

Webhook delivery sends a `POST` with the event envelope as JSON.

Headers:

- `Content-Type: application/json`
- `User-Agent: @hasna/events`
- `X-Hasna-Event-Id`
- `X-Hasna-Event-Type`
- `X-Hasna-Timestamp`
- `X-Hasna-Signature` when `webhook.secret` or `webhook.secretRef` resolves

`X-Hasna-Timestamp` is the current delivery-attempt time and is what the HMAC
signs. The envelope `time` in the JSON body remains the original event time, so
delayed imports and retries receive fresh replay-window signatures without
rewriting domain history.
Custom webhook headers whose names begin with `X-Hasna-` are rejected so channel
configuration cannot replace signed delivery metadata.

Signatures use HMAC-SHA256 over:

```text
<timestamp>.<json-body>
```

The signature format is:

```text
sha256=<hex digest>
```

Consumers can verify with:

```ts
import { verifyWebhookSignature } from "@hasna/events/signing";

const ok = verifyWebhookSignature(secret, timestamp, body, signature);
```

`verifyWebhookSignature` rejects timestamps outside a five-minute window by default.
Pass an explicit `toleranceMs` when a consumer needs a tighter or wider replay
window.

## Durable Node Producer And Bun Delivery Worker

`@hasna/events/durable-spool` is the producer boundary for Node 20+ apps. It
has no Bun runtime import and performs no network access. Each enqueue writes a
mode-0600 temporary file, fsyncs it, hard-links it without replacement to an
identity-keyed final name, removes the temporary file, and fsyncs the inbox
directory. Concurrent producers with the same `dedupeKey` converge on one
immutable record.

```ts
import { DurableEventSpool } from "@hasna/events/durable-spool";

const spool = new DurableEventSpool({ dataDir: process.env.HASNA_EVENTS_DIR! });
await spool.enqueue({
  id: "notes:note:123:created",
  source: "notes",
  type: "note.created",
  time: "2026-08-06T12:00:00.000Z",
  subject: "note:123",
  dedupeKey: "notes:note:123:created",
  schemaVersion: "notes.v1",
  data: {
    noteId: "123",
    createdAt: "2026-08-06T12:00:00.000Z",
    originMachine: "station03",
  },
  metadata: {},
});
```

`@hasna/events/durable` is Bun-only because it uses `bun:sqlite`. The broker
imports committed spool records into an event and matching-channel outbox in a
SQLite `BEGIN IMMEDIATE` transaction, then removes the spool record. SQLite WAL,
unique event/dedupe identities, unique event/channel jobs, bounded leases, and
persisted retry timestamps make separate workers and process restarts safe.
Producer spool records and broker event rows apply the default sensitive-key
redaction. Each outbox row additionally applies its channel's `redact.paths`
before the payload is persisted or delivered.
The broker initializes schema v1 only from an empty version-0 application
schema. Existing v1 databases must match the complete columns, constraints,
foreign keys, and indexes; malformed or newer schemas are rejected unchanged.

```ts
import { DurableEventsBroker } from "@hasna/events/durable";

const broker = new DurableEventsBroker({ dataDir: process.env.HASNA_EVENTS_DIR! });
broker.addChannel({
  id: "notes-created",
  enabled: false,
  transport: "webhook",
  filters: [{ source: "notes", type: "note.created" }],
  webhook: {
    url: "https://example.com/events/notes",
    secretRef: "env:HASNA_NOTES_WEBHOOK_SECRET",
  },
  retry: { maxAttempts: 5, backoffMs: 1_000, multiplier: 2 },
});

broker.importSpool();
await broker.drain();
broker.close();
```

Durable SQLite channels reject inline `webhook.secret` values. The default
resolver supports `env:VARIABLE_NAME`; callers can inject a vault-backed
resolver. Resolved credential values are used only to sign the in-memory HTTP
request and are never stored in channel configuration, the outbox, delivery
history, or status output.

Any HTTP 2xx response acknowledges delivery. The receiver therefore must return
2xx only after durably enqueueing the event and must deduplicate by `dedupeKey`
or `id`. Non-2xx responses, timeouts, and unresolved runtime secrets follow the
persisted retry schedule. Exhausted jobs remain `dead`; an operator can call
`retryDead({ eventId, channelId, limit })` or use `events durable retry-dead`
without creating a second event/channel outbox identity.

The Bun CLI exposes the same operator boundary:

```bash
events --dir "$HASNA_EVENTS_DIR" durable channel https://example.com/events/notes \
  --id notes-created --source notes --type note.created \
  --secret-ref env:HASNA_NOTES_WEBHOOK_SECRET --disabled

events --dir "$HASNA_EVENTS_DIR" durable drain --limit 100
events --dir "$HASNA_EVENTS_DIR" durable work --limit 100
events --dir "$HASNA_EVENTS_DIR" durable retry-dead --limit 100
events --dir "$HASNA_EVENTS_DIR" durable status
```

`durable drain` imports and processes one bounded batch, then exits. Enqueueing
alone never performs network delivery. `durable work` is the long-running Bun
worker: it watches only the durable spool inbox, debounces file events, imports
and drains immediately, wakes at the next persisted retry timestamp, and runs a
low-frequency bounded reconciliation in case a filesystem notification was
missed. It never watches Apple Notes or note files. SIGTERM and SIGINT stop it
cleanly. Each worker claims only the job it is about to dispatch, and only the
current lease owner can settle it. Delivery is still at-least-once: if a request
outlives its lease, another worker may retry it, so receivers must deduplicate by
`dedupeKey` or `id`. This package change does not install, start, or enable a
runner; a canary deployment must supervise one `durable work` process explicitly.

Webhook requests time out after 15 seconds unless `webhook.timeoutMs` is set.
Non-2xx responses and network failures are recorded as failed attempts. The
compatibility JSON delivery path stores response bodies truncated after 4,096
characters; durable delivery intentionally omits response bodies, stdout, and
stderr from its outbox and delivery history.

## Command Transport

Command channels run a local process and pass the event on stdin and environment variables.
For production task-created automation, route to tested package commands such as
`loops events handle todos-task` rather than long-lived local scripts. Scripts
like `scripts/handle-event.ts` are useful prototypes; repeated behavior should
move into the owning `open-*` package with tests and bounded evidence.

```ts
await events.addChannel({
  id: "local-handler",
  enabled: true,
  transport: "command",
  filters: [{ type: "repo.*" }],
  command: {
    command: "bun",
    args: ["run", "scripts/handle-event.ts"],
  },
});
```

Environment variables:

- `HASNA_CHANNEL_ID`
- `HASNA_EVENT_ID`
- `HASNA_EVENT_TYPE`
- `HASNA_EVENT_SOURCE`
- `HASNA_EVENT_SUBJECT`
- `HASNA_EVENT_SEVERITY`
- `HASNA_EVENT_TIME`
- `HASNA_EVENT_DEDUPE_KEY`
- `HASNA_EVENT_SCHEMA_VERSION`
- `HASNA_EVENT_JSON`

Command processes time out after 15 seconds unless `command.timeoutMs` is set.
Their stdout and stderr are stored on the delivery attempt and truncated after
4,096 characters. A zero exit code succeeds; other exits or signals fail.

Both implemented transports use one attempt by default. Channel retry policy
can increase `maxAttempts`; failed attempts back off from 250 ms by a default
multiplier of 2 unless overridden. The transport type union reserves `email`,
`sse`, and `mcp-relay`, but the CLI refuses to configure them and direct
dispatch currently records them as skipped.

## Redaction

Events scrub obvious sensitive keys such as `secret`, `token`, `password`,
`apiKey`, `api_key`, `api-key`, and `authorization` recursively before local
storage and delivery by default.
Callers that intentionally need raw local payloads can pass:

```ts
await events.emit(input, { redactSensitiveData: false });
```

Use channel-level paths for per-channel delivery redaction:

```ts
await events.addChannel({
  id: "secure-hook",
  enabled: true,
  transport: "webhook",
  webhook: { url: "https://example.com" },
  redact: { paths: ["data.secret", "metadata.token"] },
});
```

Use runtime hooks for app-specific policies:

```ts
const events = new EventsClient({
  redactors: [
    async (event) => ({
      ...event,
      metadata: { ...event.metadata, internalOnly: undefined },
    }),
  ],
});
```

## CLI

The package exposes `events` and `hasna-events`. `hasna-events` is a
**deprecated alias** of `events` (same entrypoint, kept for npm parity with
0.1.15); new usage should call `events` directly, and the alias is scheduled
for removal in a future major release (hasna/apps#1602).

Global options must come before the command group:

```bash
events --dir /tmp/events --json status
```

```bash
events channels add https://example.com/channels/hasna \
  --id ops \
  --type "ticket.*" \
  --secret "$HASNA_WEBHOOK_SECRET" \
  --retry-attempts 3 \
  --retry-backoff-ms 500

events channels list
events channels test ops
events channels match ops
events channels remove ops
```

`channels test` exits with code 1 when delivery fails and code 0 when delivery
succeeds or is skipped by `--honor-filters`. Its JSON delivery result and human
status output are preserved in either case.

The standalone CLI and default embedded Commander commands deny private webhook
targets unless the administrator sets `HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS`
to a comma-separated list of exact hostnames or IP addresses. For an intentional
local receiver, set `HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS=127.0.0.1`.
This setting does not change SDK defaults or override an embedded host's custom
`createClient` policy. Redirects and DNS results still undergo target validation.

Field filters can match nested `data` or `metadata` values. Plain
`--data`/`--metadata` values are strings, which keeps ids and slugs such as
`001` intact. Use `--data-json` or `--metadata-json` for typed JSON predicates.
Dot paths check both nested object keys and literal dotted keys; there is no
syntax to distinguish between those two forms yet. A negative predicate also
matches when its field is absent.
When the actual event value is an array, string filters match any primitive
array member, which is useful for tag routing such as `data.tags=auto:route`.
Use `path!=value` or `path!=json` for negative predicates such as
`metadata-json 'automation.no_auto!=true'`. Wildcard behavior stays broad for
legacy source/type/subject filters. For field paths ending in `_path` or `.path`,
`*` matches one path segment and `**` matches recursively.

```bash
events channels add loops \
  --id open-source-task-route \
  --transport command \
  --source todos \
  --type task.created \
  --timeout-ms 15000 \
  --retry-attempts 3 \
  --retry-backoff-ms 500 \
  --metadata 'project_path=/home/hasna/workspace/hasna/opensource/*' \
  --metadata-json 'route_enabled=true' \
  --metadata-json 'automation.no_auto!=true' \
  --data 'tags=auto:route' \
  --arg events \
  --arg handle \
  --arg todos-task

# Command args that begin with dashes can be passed either form:
events channels add events --id json-route --transport command --arg --json
events channels add events --id json-route --transport command --arg=--json

# For nested CLIs, put child positional args and flags after an explicit delimiter.
events channels add events --id nested-route --transport command -- handle todos-task --json

events channels match open-source-task-route \
  --source todos \
  --type task.created \
  --metadata '{"project_path":"/home/hasna/workspace/repos/hasna/events","route_enabled":true}'

events channels test open-source-task-route --honor-filters \
  --source todos \
  --type task.created \
  --metadata '{"project_path":"/tmp/outside","route_enabled":true}'
```

Emit, list, and replay:

```bash
events events emit ticket.created \
  --source tickets \
  --subject ticket:123 \
  --severity notice \
  --message "Ticket created" \
  --data '{"ticketId":123}'

events events list --limit 20
events events list --source tickets --type ticket.created
events events replay --type ticket.created
events events replay --type ticket.created --dry-run --limit 100
events events replay --type ticket.created --cursor "$NEXT_CURSOR" --limit 100
events events replay --dry-run
```

Replay cursors are opaque and tied to the same filter set (`--id`, `--source`,
and `--type`) used to produce them. Use the `nextCursor` returned by the
previous JSON replay response rather than constructing cursor strings in
callers. A replay without `--limit` or `--cursor` processes all matching events;
use those flags when callers need bounded page-by-page replay.

Machine-readable status (global flags precede `status`):

```bash
events --json status
```

The status contract reports storage runtime, event, channel, delivery, file, and
transport metadata only. It does not include event payloads, webhook signing
secrets, command environment values, or channel targets.

Use `--json` for script-friendly output and `--dir <path>` for isolated data.
The standalone `events events list` command has no implicit row cap. Commands
registered into another Commander program with `registerEventsCommands` default
to the 100 most recent rows, accept a host override, and use `--limit 0` to list
all rows. The embedded emitter also supports `--no-dedupe`; the standalone CLI
does not currently expose that library option.

## App Integration Pattern

Apps should keep event emission near durable state changes and avoid hardcoding app-specific channels. The common pattern is:

```ts
import { EventsClient } from "@hasna/events";

const events = new EventsClient();

export async function recordDomainEvent() {
  await events.emit({
    source: "your-app",
    type: "domain.object.changed",
    subject: "object:123",
    severity: "info",
    data: { id: 123 },
  });
}
```

Local users and agents can configure channels once through the CLI, and every app using `@hasna/events` will share the same local channel config.

## Development

```bash
bun test
bun run typecheck
bun run build
```


## Authenticated durable intake

`events-serve` provides a separate PostgreSQL intake service. The existing Events
CLI, local storage exports and filesystem/SQLite outboxes retain their documented
compatibility behavior. Writing a local spool is not remote acceptance. This
change does not deploy a service or wire Conversations delivery to it.

The `@hasna/events/intake` client uses the shared saved-credential chain for the
Events API. Set an explicit API authority for a deployment; the default gateway
is not evidence that this new capability is deployed. It never opens local data
storage or accepts an unsigned receipt. Calls require a frozen sink, producer,
corpus, source-authority and tenant identity established by an operator.
Corpus and source-authority IDs are exact 1–128 character identifiers matching
`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`, including existing `cor_` corpus IDs.
No trimming, case folding or identity rewriting occurs. Sink and producer IDs
remain UUIDs. Explicit owner migration `events_intake_0002` upgrades the binding
columns while preserving existing UUID values and receipts; service readiness
refuses a missing or mismatched migration checksum or incompatible column types.

```ts
import { createIntakeClient, prepareIntake } from '@hasna/events/intake';
// sink_id and producer_id are UUIDs; corpus_id and source_authority_id preserve source-owned IDs.
const client = createIntakeClient({ binding, tenantId });
await client.capability();
const frozenRequest = prepareIntake(binding, envelope);
const receipt = await client.accept(frozenRequest);
// Only status === 'accepted_durable' acknowledges a committed destination record.
```

Persist the frozen request in the producer's transactional outbox before sending.
A network timeout or lost response is uncertain: retain the source checkpoint and
use `client.receipt(frozenRequest)` or resend the same request. An absent receipt
is not permission to generate a new identity. The client does not automatically
retry POST. A rotated key works only when the operator has granted that active,
registered Events key to the same tenant and producer. Revocation also blocks
replay and historical receipt lookup.

The wire protocol is `hasna.events.intake.v1`, encoding `hasna.sorted-json.v1`:
recursively sorted object keys, original array order, JSON scalar encoding, exact
UTF-8 SHA-256. This is a versioned Hasna encoding, not RFC 8785. The envelope text
must exactly equal its canonical re-encoding (including no duplicate keys). It
must use EventEnvelope schema `1.0`, include a stable `id` and `dedupeKey`, and
match the registered producer app in `source`. `metadata.app_event` remains small
producer metadata; it is not interpreted as the separate full AppEvent schema.
Envelope size is at most 256 KiB, depth 32 and 20,000 JSON values. Sensitive key
names and recognizable credentials are rejected with fixed diagnostics, never
silently redacted after the hash is frozen. Producers remain responsible for
omitting sensitive content before constructing an envelope.

Authenticated routes are `POST /v1/intake/events`, `GET /v1/intake/capability`,
and `GET /v1/intake/receipts?event_id=...`. They require `x-events-tenant-id`,
`x-events-sink-id`, `x-events-producer-id`, `x-events-corpus-id` and
`x-events-source-authority-id` headers. The signed tenant must match the expected
tenant header and persisted producer grant. POST requires `events:intake`;
readback/capability requires `events:receipts`. A body cannot choose a tenant.
Receipts contain identity, hash, receipt UUID and acceptance time, never envelope
content. Identical replay returns the original receipt; either unique event ID
or dedupe key reused with different content produces HTTP 409.

### Explicit operator initialization

Use a dedicated PostgreSQL schema and a separate owner connection for admin
operations. Supply `HASNA_EVENTS_DATABASE_URL` securely; never put a production
connection string or signing key in shell arguments. Server startup never runs
migrations or adopts the first caller. Under the schema owner connection:

```sh
events-serve admin init --sink-id "$SINK_ID" --authority-id "$AUTHORITY_ID"
events-serve admin bind --producer-id "$PRODUCER_ID" --tenant-id "$TENANT_ID" --app conversations --corpus-id "$CORPUS_ID" --source-authority-id "$SOURCE_AUTHORITY_ID"
# Register an already-issued signed key injected as HASNA_EVENTS_ADMIN_API_KEY,
# or, locally, piped from a secret manager: secret-manager-read | events-serve admin register-key
events-serve admin register-key
events-serve admin grant --tenant-id "$TENANT_ID" --producer-id "$PRODUCER_ID" --kid "$KEY_ID"
events-serve admin revoke --tenant-id "$TENANT_ID" --producer-id "$PRODUCER_ID" --kid "$KEY_ID"
```

#### Secret inputs for admin operations

Every admin operation takes its secrets only from the process environment,
which is how a one-shot container task receives injected secrets. No admin
operation needs a terminal or stdin; the stdin pipe below still works locally.

| Variable | Used by | Holds |
| --- | --- | --- |
| `HASNA_EVENTS_DATABASE_URL` | every operation | the schema owner connection |
| `HASNA_EVENTS_API_SIGNING_KEY` | `register-key` | the Events key signing secret |
| `HASNA_EVENTS_ADMIN_API_KEY` | `register-key` | the already-issued Events API key |
| `HASNA_EVENTS_ADMIN_API_KEY_FILE` | `register-key` | alternatively, the absolute path of a regular file holding that key, owned by the caller with mode `0600` or stricter |

When either key variable is set, `register-key` uses it and never touches
stdin; setting both is refused. A variable that is set but empty counts as given
and fails closed. The file check refuses a symlink only as the final path
component: parent directories are followed, so the caller-owned `0600` (or
stricter) mode is what protects the file.

With neither variable set, `register-key` keeps its original behaviour and reads
the key from piped stdin, which must not be a terminal. Closed or empty stdin,
as in a one-shot task started without the key variable, fails closed at EOF with
`admin_api_key_input_required`. `--key-source stdin` selects the same stdin
route explicitly and is refused when either variable is also set:

```sh
secret-manager-read | events-serve admin register-key
secret-manager-read | events-serve admin register-key --key-source stdin
```

Other operations ignore the key variables, so one admin task definition that
carries all three secrets can run any operation. Identifiers, types and subjects
are not secret and come from arguments. A secret in arguments is refused before
any database contact: a secret-named flag (`--key`, `--api-key`, `--token`,
`--signing-key`, `--database-url`, ...), a Hasna API key, a PostgreSQL
connection URI, or any value containing the value of `HASNA_EVENTS_DATABASE_URL`,
`HASNA_EVENTS_API_SIGNING_KEY` or `HASNA_EVENTS_ADMIN_API_KEY`. A key supplied
through `HASNA_EVENTS_ADMIN_API_KEY_FILE` is not in the environment, so in
arguments it is caught only by the Hasna key-shape pattern.

This guard is defence in depth, not a control to rely on: a deliberately
obfuscated or split secret can pass it. Anything placed in arguments is already
exposed to other local users through the process list before the command runs,
so a refused secret still counts as exposed and must be reported through the
incident path.

`register-key` requires `HASNA_EVENTS_API_SIGNING_KEY` and validates the signed
Events tenant claim. It stores only the shared hashed API-key record and never
prints the key. Its output is metadata only: the key id, tenant, scopes, expiry,
key length, input source and the first 8 hex characters of the key's SHA-256.
A key whose key id or hash is already registered is refused with
`events_admin_key_already_registered` and a non-zero exit; a repeat is never a
silent success. Every other operation prints its status and operation name.
Refusals print only a fixed error code. `revoke` without `--kid` revokes the whole producer. Identical
initialization is replayable; sink/producer identity reassignment is refused.
These owner operations are not exposed through HTTP. A portfolio or environment
value is not proof of corpus ownership; operators must verify the source corpus
binding before granting it here.

Give the runtime role schema USAGE, SELECT on `events_intake_migrations` and
`events_intake_identity`, SELECT and INSERT on `events_intake_records`, and
SELECT/UPDATE on `api_keys`,
`events_producer_bindings` and `events_producer_key_grants`. UPDATE is required
by PostgreSQL row locks; owner-only triggers reject runtime mutation of these
control tables. Do not grant schema CREATE, table ownership, membership of the
owner role, SUPERUSER or BYPASSRLS. Set a fixed search_path for both connections.
FORCE RLS scopes every tenant table using transaction-local context. Record
updates/deletes/truncation and identity changes are rejected by database triggers.
Provision the backing volume, backups and PostgreSQL durability settings before
production use; the service's receipt proves COMMIT, not an independently tested
backup or infrastructure configuration.

Run `events-serve` under the restricted role with
`HASNA_EVENTS_API_SIGNING_KEY`, `HASNA_EVENTS_SINK_ID`, and
`HASNA_EVENTS_AUTHORITY_ID`. It refuses an owner/BYPASSRLS role or mismatched sink.
The default listener is loopback port 3000; use `HOST`/`PORT` explicitly behind a
trusted HTTPS reverse proxy. `/health` proves process liveness; `/ready` verifies
the configured sink and restricted database role. Neither probes a producer key.

### Required PostgreSQL verification

`bun run test:postgres` is a fail-closed gate, also run by the dedicated CI
workflow. It accepts only an explicit disposable `events_test` user/database on
literal loopback with an explicit port, uses unique schemas and a non-owner,
non-BYPASSRLS runtime role, and never resets public. Missing DSN, skipped cases,
partial summaries and crashes fail the gate. The ordinary suite may skip these
37 database cases when no test DSN is supplied; this is not database acceptance.


The service also provides `events intake capability|accept|receipt` and the
intake `events-mcp` tools `events_intake_capability`, `events_intake_accept`,
and `events_intake_receipt`. Both use the same authenticated client and receipt
validation; the accept/readback commands take the frozen JSON request through
stdin. They do not expose legacy local spool or channel operations. Run their
`--help` commands for identity selectors. A failed tool call reports unconfirmed,
not cancelled; retain the producer checkpoint when no verified receipt is returned.

The served `/openapi.json` combines the intake and consumer feed operations. `bun run
sdk:generate` derives the typed route client used by the binding-aware wrapper;
`bun run sdk:check` rejects schema/client drift. The root SDK retains its library
exports and additionally exposes `createIntakeClient` and `prepareIntake`.

Acceptance transactions force `synchronous_commit=on`; readiness also refuses a
PostgreSQL server with `fsync` or `full_page_writes` disabled. The included
Dockerfile packages only the intake service. Build/run infrastructure and operator
initialization remain explicit deployment tasks, not effects of this source patch.

## Scoped event consumers

The hosted consumer API lets a coding agent or another application read events
already accepted by Events. It does not monitor arbitrary databases, poll other
applications, run webhooks, or start model turns. A producer must publish its
reviewed event envelopes through durable intake first. App-specific clients may
continue to read their owning APIs until such a producer is connected.

Use `@hasna/events/feed`, `events feed`, or the `events_feed_grants`,
`events_feed_capability`, and `events_feed_read` MCP tools. These clients use the
same package-owned credential resolver as intake and never create a local event
store. The default authority is `https://api.hasna.com/events`; a self-hosted
installation uses its explicit `HASNA_EVENTS_API_URL` override, without `/v1`.
The target service and grants must be provisioned before these reads can work.
`events-mcp --stdio` supports both intake and consumer tools.

Payload access is distinct from receipt access. A signed key needs
`events:consume` **and** an owner-provisioned grant for its exact tenant,
consumer UUID and producer UUID. The immutable producer binding carries its app,
corpus and source-authority identity. Each grant names 1–50 exact event types and
optionally one exact subject, such as a channel or document identifier. A caller
may narrow that filter; it cannot broaden the grant. Wildcard event types are not
accepted. Discover only the active grants belonging to the authenticated key:

```sh
events feed grants --tenant-id TENANT_ID
```

Follow `next` with `--after` until it is null. API routes are
`GET /v1/feed/grants`, `GET /v1/feed/capability` and `GET /v1/feed/events`.
Capability and event reads take the same binding headers as intake, plus
`x-events-consumer-id`; filters use repeated `type` query parameters and an
optional `subject`. The served `/openapi.json` documents the full contract and
generates the typed HTTP methods used by the SDK.

```ts
import { createFeedClient } from '@hasna/events/feed';

const client = createFeedClient({ tenantId });
const { grants } = await client.grants();
const grant = grants[0];
if (!grant) throw new Error('An operator must grant this consumer access first');
const filter = { types: grant.types, ...(grant.subject ? { subject: grant.subject } : {}) };
await client.capability(grant, filter);
const start = await client.read(grant, filter); // initializes at now; no history replay
// Retain start.cursor in the consumer's checkpoint store.
const page = await client.read(grant, filter, { cursor: start.cursor });
// Deliver page.events through the consumer's native event interface.
// Save page.cursor only after every event in this page was admitted successfully.
```

The first read returns an empty page and a signed cursor at the current commit
position. It does not replay old activity. A page contains at most 20 events and
512 KiB of canonical envelope bytes. If `has_more` is true, subsequent pages use
the returned cursor. Poll at most once per minute and bound pages per cycle so
interactive commands can interleave. The service does not acknowledge or save a
consumer's cursor. If delivery is deferred or crashes, retain the old cursor and
read the same page again; deduplicate by the authority-qualified producer and
event ID. An irrelevant filtered event may advance the cursor, but an
undelivered matching event never does.

Cursors are tied to the tenant, key, consumer, producer, corpus, source authority,
filter and active grant generations. A changed binding requires an explicit new
subscription; do not silently restart from now after a cursor refusal. Revoked
keys, producers and consumer grants deny historical reads too. Preserve the
source, sender metadata, subject/thread identifiers and timestamp when adapting
an envelope. Event content is untrusted data, not system instructions. Channel
traffic should form passive digests; the harness owns any explicit wake policy.

### Operator migration and grants

For deployments behind a trusted API gateway, optionally configure
`HASNA_EVENTS_ORIGIN_AUTH` on the server and have the gateway replace the
`x-hasna-origin-auth` header with that value. It must contain 32–256 ASCII
letters, digits, underscores or hyphens, with no trimming. An unset variable
preserves direct self-hosted access; a configured empty or malformed value
refuses server startup. Missing, malformed, duplicated or incorrect origin
headers receive a value-free `origin_refused` response before API-key checks.
Origin admission does not replace API keys, tenant checks, grants or RLS.

Only exact `GET /health` and `GET /ready` requests are exempt so a load balancer
can probe the origin without that header. Use `/ready` for the target group's
health check: it still checks the database, schema, role and feed privileges,
and returns 503 when those checks fail. Version and OpenAPI requests require
origin admission when configured. The server does not create or distribute
origin credentials or configure a gateway.

Apply the additive `events_feed_0001` migration through the existing owner-only
migration command before starting the new service. Preserve and verify the
existing database backup under the operator's recovery policy first. Migration
adds consumer grants and an immutable feed index; it neither rewrites nor
backfills historical intake records. Its intake trigger takes a transaction lock
before allocating the sequence, so one producer's cursor cannot pass a record
whose transaction has not committed.

In addition to the intake runtime privileges above, grant the runtime role
`SELECT, INSERT` on `events_feed_log`, `SELECT, UPDATE` on
`events_consumer_grants`, and `USAGE` on `events_feed_log_sequence_seq`.
The service must remain a non-owner without schema CREATE or BYPASSRLS.
Owner-only triggers reject control-table mutations; direct feed inserts and
record updates, deletion or truncation are refused. `/ready` fails when the
feed migration or required runtime grants are missing. Complete migration and
privilege provisioning before directing traffic to the new service.

Owner-only administration adds two operations, using the existing
`events-serve` admin invocation:

```text
grant-consumer --tenant-id TENANT_ID --consumer-id CONSUMER_UUID --producer-id PRODUCER_UUID --kid KEY_ID --types TYPE[,TYPE] [--subject SUBJECT]
revoke-consumer --tenant-id TENANT_ID --consumer-id CONSUMER_UUID --producer-id PRODUCER_UUID --kid KEY_ID
```

Granting identical access is idempotent. Existing identities and filters cannot
be changed in place or silently reactivated after revocation; provision a new
reviewed consumer identity or key. These are database-owner operations, not HTTP
or agent tools. Revocation remains subject to the operator's authorization and
preservation requirements. No service, credential, source producer or cloud
resource is created by installing this package.
