# Embedded daemon lifecycle

## Daemon entry points

## Auth process runner

`runAuthService(args, store?, options?)` reads its provisioned long-lived secret kinds (service keys, callout
account, issuer keys, owner secret) through the injected `SecretStore`; a host provisions those into
the store first. It is a **signer and identity authority**, not a scoped daemon: at runtime it holds
the data-account and callout-account signing seeds, the issuer's private JWKs, and the
owner-derivation secret in process memory (`SecretStore.get` exports raw values). The IdP pin and the
actor ledger are **not** store-injected: `runAuthService` resolves them under
`userAuthStateDir(findCotalRoot(), space)`, a path relative to the process working directory, so a
host provisions those into that exact directory (neither `store` nor `COTAL_HOME` selects it). It
also writes an ephemeral `auth-service.json` discovery file there that carries the live exchange
capability. That file appears only after every plane is bound, so waiting on it is the readiness
signal: a host that also passes the daemon's pid to the provider's `ready()` gets a process-bound
wait. The wait extends past the base timeout while that pid is alive, up to a fixed bound, and it
ends at once when the pid exits. A daemon that refused its start leaves that refusal in
`auth-service-refusal.json`, bound to its pid, and `ready()` throws it as an `EpEnvelopeError`.
A plane-claim refusal keeps its code and `details[].reason`.

```ts
import { runAuthService } from "@cotal-ai/auth";
// store implements SecretStore over your secret backend; get() returns raw seeds into memory.
// Provision the auth secret kinds into the store, AND the IdP pin + actor ledger under
// userAuthStateDir(findCotalRoot(), space), before this call.
await runAuthService(
  { values: { space, server: brokerUrl, port: "8081" }, positionals: [], raw: [] },
  store,
);
```

## Delivery process runner


### Delivery administration

`runDelivery(args, store?, onStarted?)` runs from a **pre-minted scoped `delivery` cred** and never loads the
signer. Provide the cred either through the injected store (under
`deliveryCredsKey(space, { injected: true })`) or with a
`--creds` file; the two are mutually exclusive. The daemon re-fetches the cred from the store at 75%
of its JWT lifetime and fails loud rather than riding to expiry, so **something must re-sign a fresh
cred into that same store**. When that read finds the previous generation still there, the daemon
reports the missed remint and retries in 60 seconds. The current cred stays live until its expiry,
and the store is read once per retry rather than once per second.

```ts
import { runDelivery } from "@cotal-ai/delivery";
await runDelivery({ values: { space, server: brokerUrl }, positionals: [], raw: [] }, store);
```

That renewal is a **signer** operation, not the delivery daemon's:
`remintDaemonCreds(root, space, store?, { preflight? })` (`@cotal-ai/workspace`) reads the `SpaceAuth`
signer **through the same resolved `store`** (`getSpaceAuth(store ?? workspaceSecretStore(root), space)`,
keys `auth/broker.json` + `auth/account.<key>.json`; the pre-split `auth/auth.json` monolith is
migration input and the container signer mount only) and re-signs the daemon creds (`delivery.creds` and the membership feed's
`membership-rw.creds`) back into that store. The injected `store` is both the signer source and the
credential destination, never a split. `space` is **required** and validated against the store's signer, so a
store swapped to a different space cannot re-sign over the wrong broker's creds. `preflight` is a
caller-supplied proof that the broker accepts the credential. The reference `Manager` passes a
`probeConnect` over its `servers`. It gates **every** candidate before overwriting the last-good,
whether the signer is a full bundle or a stripped projection: a bundle's JWT chain proves only that
it is self-consistent and
named the space, NOT that its account is the broker's *current* account for that space (two
`createSpaceAuth(space)` calls yield same-named, different-account chains), so a same-label alternate
signer would otherwise mint a broker-dead cred and clobber the good one. The offline local repair (`doctor auth --fix`) has no preflight. It permits the overwrite only
under **authority continuity**: the candidate must be signed by the same account signing key (`iss`) as the current
(already broker-accepted) cred. A same-label alternate account breaks continuity and is refused, full or
stripped; a legitimate local re-sign is continuous and proceeds without a network. The reference
`Manager` runs it on a schedule against its **own**
`secretStore` (see below), so passing the manager and the delivery daemon the *same* store closes the
renewal loop end-to-end on an injected backend: the manager reads the signer from the store, re-signs
into it, and the daemon adopts each generation on a preflight-proven 75% timer. The stock
cross-host composition cannot satisfy that by writing one filesystem and fingerprinting another:
`Manager.start()` and every later remint challenge the daemon's `reloadStoreIdentity` and a
divergent pair is refused naming both stores. The identity is the store the daemon actually
reloads: an injected coordinate, the workstation root only when `--creds` is
`<root>/.cotal/<spaceSegment(space)>/delivery.creds` (matching the canonical arm), the
file's own directory for any other `--creds` path, or the workstation root. A filesystem
store is also named by a random id it records in `store.id` inside its own directory, so two
hosts that use the same root path are two stores. No key and no `--creds` file may be that
file under any name, and a `store.id` that is a symbolic link or holds anything but a lowercase UUID is refused. Uninjected
`--creds` that names one real workstation while process cwd resolves another is refused
at start, naming both, because membership-rw still uses `findCotalRoot`. A `--creds`
path that is not under any `.cotal` tree is not that case and is not refused here. It never
walks ancestors with `findCotalRoot`. No bound daemon is not a named
store, so start proceeds; a later daemon on a foreign store is refused on the next remint.
The first-party filesystem adapter declares its workspace-root identity on the store itself. Other
injected adapters declare their stable coordinate on `SecretStore.identity`, or name it in
`COTAL_SECRET_STORE` on both processes. It never throws: it
returns per-file results (`skipped: "no-auth"` when the store holds no signer records),
so the caller must check them or the cred still rides to expiry. A composition whose signer lives in
KMS/Vault simply injects that store; no bespoke renewal is needed. A `--creds` file path must be
replaced atomically before the 75% read. The signer can now be injected behind the store seam, which
resolves custody. The remaining hosted gap is signer **isolation**. The seed is still decrypted
in-process at the manager's uid, so it needs an OS sandbox or remote signer.

### Delivery readiness

Delivery reports ready only after its lease and reader, membership, and administration bindings are established. A lease alone proves no serving responder. See [delivery service contracts](/docs/delivery-service-reference/#where-it-runs) for startup, adoption, retry, and lease-failure outcomes.

### Broker connection health

The daemon closes when its broker-gone watchdog expires. Disconnection, serving readiness, and credential health remain separate observations. See [delivery service contracts](/docs/delivery-service-reference/#where-it-runs).

### Checkpoint timer writer

The timer writer uses the daemon's current delivery credential and reconnects after each adoption. Stop ends its pending fetch and retry backoff immediately. A writer failure does not take message delivery down. See [delivery service contracts](/docs/delivery-service-reference/#where-it-runs).

## Hosted delivery context


### Delivery reader cleanup

For an embedded delivery instance, use `startDeliveryService` instead. Its `HostedContextInputs`
include the account public key and lifecycle UID, space, broker URL, injected store, stable
`storeIdentity`, and an explicit `stateDir`. The store must declare that same injected identity.
The initial delivery credential must belong to the assigned account. The function returns only
after the delivery responder is bound. `close()` withdraws serving and releases only the lease
owned by that instance. It closes both membership connections even when a disconnected drain
fails, so they cannot reconnect after closure. It rejects when its release does not commit or
the broker cannot confirm it, or when another instance's lease row is still there afterwards.
If the delivery connection drops or is down while the release is in
flight, `close()` waits up to the broker-gone window (`COTAL_DELIVERY_BROKER_GONE_MS`, 15 seconds
by default) from the drop for it to reconnect, and repeats the release once on the reconnected
connection before it decides. `drain()` returns the same promise. A successor is refused while such
a row lives, and the bucket TTL expires a row the instance left. Credential-expiry health state clears after
successful broker-verified adoption through the existing `reloadCreds` rail. A failed start
refuses locally without exiting the host process or stopping another account's delivery service.


### Startup failure

If a health fault occurs during an asynchronous store read, startup rejects when the read returns
and closes any resources created by that late completion.

## Hosted auth context


### Auth handle

`startAuthService` takes the same `HostedContextInputs`. The store must declare the assigned
injected identity, and its data account must be the assigned account. The IdP pin and ledger live
under the explicit `stateDir`. The auth plane's instance identity holds its private serve seed, so it
lives in the store under `authInstanceKey(space)`. A record an earlier release kept under `stateDir`
moves into the store on the first start. If the store and file disagree on the instance id,
public serve key or serve seed, startup refuses and leaves both identity records intact.
The context never resolves a workspace root from the working directory and has no local manager, so
only remote manager gates can be selected. It returns after
the authority plane, the callout subscription and the loopback listener are bound. A start that
fails closes the connections it opened and releases its plane claim, so a retry on the same space
can claim it. A fenced plane or
a lost broker connection makes that context `unavailable` and closes it without exiting the process.
The host writes no discovery file for it.

### Public exchange face

The handle carries what `auth-service.json` holds for a
CLI start: the loopback `url`, `publicUrl` when a public face runs, and the per-start `cap`. The cap
alone authorizes the loopback host actions, lifecycle retirement and managed-agent enrollment
verification, so keep it in the authority process. `publicFace` takes the CLI's public face inputs
(`port`, `url`, `trustedProxy`, `advertisedServer`, `agentProvisioningUrl`) under the same rules,
and a face without a port refuses to start.


### Platform control

Two optional inputs serve a platform composition. `platformControl: { observeAssignment }` adds
`platformControlAuthority` to the handle. It is a typed in-process method, served on no listener,
that issues the manager-service request family for the one control manager the backend assigned to
this account, under a derived `p_` owner. It reads the assignment fresh on every call and refuses
an IdP token, another account, a stale revision, another instance or lifecycle, and an instance
another owner registered. It refuses `prepare` and `activate` while the assignment's named
predecessor is still registered or frozen. The same input adds `platformControlReadiness(instanceId)`,
the route for a host that needs to know whether its assigned control manager is serving. It returns
that instance's attributed `status` reply and refuses any instance the current assignment does not
name, or one whose gate another owner holds. It reads over the context's own connection, whose
grant is the assigned instance's `describe` and `status` and its own reply rail. That connection
renews in process like the context's other connections and never leaves it, so the host lends no
human or operator credential to a worker, mints no control instrument per read, and does not read
liveness off the manager process. Without the input both members are `undefined`.
`standingRenewableTtlSeconds` is forwarded unchanged to the authority plane, which bounds it to 5
to 86400 seconds. It is a trusted-host input for the renewal rehearsal, and no request or CLI flag
sets it. `issuerSessionTtlSeconds` sets the credential lifetime of the plane's held issuer session
the same way, bounded to 5 to 300 seconds, with the five-minute `issuer` lifetime as the default. SPEC §13.1 and §13.6 define the view.

### Claim recovery order

The auth plane can renew a registered manager's five standing credentials from the current
service registration. Run-driver renewal reads the hosted run attempt and checks its active
fence as well as the manager's open issuance gate and process epoch. A moved gate or run
fence refuses issuance. These primitives do not establish a complete pooled deployment.
A fresh auth plane can initialize without a delivery-admin responder. Reclaiming a held claim from a dead predecessor needs the delivery
instance first: its admin rail must complete the broker connection-liveness sweep before the
auth plane takes the claim. An absent or inconclusive oracle refuses the reclaim.

## Delivery lease watches

The delivery lease watch also retires its ordered consumer on an explicit stop,
endpoint stop, manual reconnect of a live connection, or replacement during binding.
Endpoint stop and manual reconnect wait for in-flight binds and cleanup before closing the connection,
bounded by the JetStream request timeout. A watch that finishes binding to a retired
connection is deleted instead of installed. A connection that already closed cannot
carry the delete, so the broker reclaims its consumer after five minutes of inactivity.
A failed delete emits a warning and also leaves reclamation to the broker.
[`watchDeliveryLease` in endpoint.ts](https://github.com/Cotal-AI/Cotal/blob/main/packages/core/src/endpoint.ts) owns this behavior.

## Daemon credential renewal

`remintDaemonCreds(root, space, store?, { preflight? })` is exported by `@cotal-ai/workspace`.
It reads the `SpaceAuth` signer and writes renewed `delivery.creds` and `membership-rw.creds` through the same resolved store.
The injected store is both signer source and credential destination.
Without injection, the source is `workspaceSecretStore(root)`.
The split signer keys are `auth/broker.json` and `auth/account.<key>.json`.
The older `auth/auth.json` bundle is migration input and the container signer mount.

The required `space` is checked against the signer.
A store holding another space cannot overwrite this space's credentials.

### Candidate validation

A supplied `preflight` proves the broker accepts each candidate before the last good credential is overwritten.
The reference Manager uses `probeConnect` against its `servers`.
A self-consistent JWT chain with the right space label is insufficient.
Two `createSpaceAuth(space)` calls create different account chains with the same label.

Offline `doctor auth --fix` has no broker preflight.
It permits replacement only when the candidate's account signing key, `iss`, matches the current broker-accepted credential.
An alternate account breaks that continuity and is refused, for both full and stripped signer bundles.
A continuous local re-sign requires no network.

The Manager schedules remint against its own `secretStore`.
Sharing that store with delivery completes the renewal cycle from signing to adoption.
The daemon's adoption still performs a broker preflight at its 75% renewal point.

The explicit `reloadCreds` operation refuses adoption until delivery has finished startup,
after its lease watch binds. The lease can be ready before that point, so an early renewal request
can reach a responder that refuses with `this daemon has not finished starting` and adopts nothing.
The next renewal pass or the daemon's own 75% re-read can adopt the re-signed credential.

### Store identity

Manager startup and every later remint challenge delivery's `reloadStoreIdentity`.
Different stores are refused with both identities reported.
Writing on one host's filesystem does not renew a daemon reading another host's filesystem.

The identity describes the source delivery actually reloads:

| Credential source | Identity |
|---|---|
| Injected store | Its declared stable coordinate. |
| Canonical `<root>/.cotal/<spaceSegment(space)>/delivery.creds` | Workstation root. |
| Other `--creds` path | The file's own directory. |
| Default filesystem source | Workstation root. |

Filesystem stores also record a random lowercase UUID in their own `store.id`.
Equal path strings on two hosts therefore identify different stores.
A symbolic-link or malformed `store.id` is refused.
No secret key or `--creds` path may name that identity file.

An uninjected `--creds` path under a real workstation is refused when it names a different root from the process working directory.
Membership writes still resolve that working-directory root.
A credential path outside every `.cotal` tree does not trigger this check.
The credential identity calculation does not walk ancestors through `findCotalRoot`.

When no daemon is bound, no daemon store identity is available and startup proceeds.
A daemon later bound to a foreign store is refused at the next remint.
The filesystem adapter declares its workspace-root identity on the store.
Other injected adapters declare `SecretStore.identity` or use the same `COTAL_SECRET_STORE` coordinate in both processes.

### Renewal results

`remintDaemonCreds` returns per-file results instead of throwing.
When signer records are absent, it reports `skipped: "no-auth"`.
The caller must inspect those results or credentials can reach expiry despite a completed function call.
A file-based source requires atomic replacement before the 75% read.

A remote secret backend changes where keys are stored.
`SecretStore.get` still returns raw signing material into the Manager process.
Agent isolation or a separate signing service remains necessary when those processes are not equally trusted.
