/** * Celilo Module Contract — v1.0 * * Defines the canonical signature of every lifecycle hook in contract version * "1.0". A module that declares `celilo_contract: "1.0"` in its manifest * promises to implement these hooks with these exact inputs and outputs. * * Why this exists: * Before contract versioning, every module redeclared inputs/outputs arrays * for every hook in its manifest. That meant if Celilo changed the * canonical signature of a hook (say, added a required input to on_backup), * we had to hand-edit every manifest to match. This file is now the single * source of truth. * * The contract is enforced by `validateHookInputs` and `validateHookOutputs` * in `apps/celilo/src/hooks/executor.ts` — at execution time the executor * looks up the canonical inputs/outputs from the contract registered for the * manifest's declared version. * * Adding a new optional input or output is a v1.x additive change. * Adding a new required input or removing a required output is a breaking * change and requires a v2.0 contract. */ import type { HookName, PathAccess } from '@celilo/capabilities'; /** * Per-input/output metadata. * * `required: true` — the executor enforces presence at runtime. * `required: false` — the value is allowed but not mandatory; modules may * produce or accept it without violating the contract. */ export interface ContractField { required: boolean; /** * Set when the framework supplies a filesystem path in this field, and the * access the hook is given to it. Absent means "not a path". * * The hook jail derives its bind mounts from this (design D9). Path-ness is * NEVER inferred from the field name at runtime: a name heuristic silently * changes behaviour the day somebody adds an input called `workspace`, and * the symptom is an ENOENT on a path that visibly exists on the box. * * A field carrying a path-shaped value with no annotation here is a defect. * `db_path` was one for months — passed by `backup-create.ts` and declared * nowhere, so anything reasoning from this table was wrong about what a * backup hook receives. It is gone: design D9b replaced it with the staged * `system_state_root` below, which is declared and annotated. */ path?: { access: PathAccess }; } /** * Signature for a single lifecycle hook within a contract version. */ export interface ContractHookSignature { /** Inputs the executor must supply when calling the hook. */ inputs: Record; /** Outputs the executor enforces against the hook's return value. */ outputs: Record; } /** * Full contract for a version: a map of canonical hook name → signature. * Only hook names listed here are valid; declaring a hook not in this map is * a manifest validation error. * * Keyed by `HookName` rather than `string` (celilo#821). That single change is * what turns this table from a fourth hand-maintained list into a derivation: * a name added to `HOOK_NAMES` without an entry here is a type error, and an * entry here for a name that is not a hook is one too. It was `Record`, which is why the drift went unnoticed for months. */ export type ContractHooks = Record; /** * Contract v1.0 — current canonical hook signatures. * * These mirror the inputs/outputs that pre-Phase-2 manifests declared * inline. The values were derived from a survey of every active manifest in * the repo at the time the contract was minted. */ export const V1_HOOKS: ContractHooks = { container_created: { inputs: {}, outputs: {}, }, on_install: { inputs: {}, outputs: {}, }, on_uninstall: { inputs: {}, outputs: {}, }, /** * A module that CONSUMED one of this module's capabilities is being removed. * The provider withdraws whatever it minted on that consumer's behalf * (openspec/changes/consumer-removal-cleanup, D1). * * `consumer` is the only input and there are no outputs. Deliberately NOT * accompanied by the list of capabilities the consumer used: a provider that * cannot answer "what do I hold for this module" without being told has a * different defect — the consumer's id was never recorded at the point of the * call — and telling it at removal time only papers over that. */ on_consumer_removed: { inputs: { consumer: { required: true }, }, outputs: {}, }, health_check: { inputs: {}, outputs: {}, }, validate_config: { inputs: {}, outputs: {}, }, on_backup: { inputs: { backup_dir: { required: true, path: { access: 'write' } }, /** * Path to a directory containing read-only mirrors of OTHER modules' * `generated/terraform/` trees, plus an `index.json` enumerating * deployed modules. Populated by the framework ONLY when the * declaring module has `cross_module_read` in its * `requires.capabilities` AND is on the privilege allow-list (see * `validatePrivilegedCapabilities` in manifest/validate.ts). Today * only celilo-mgmt is allow-listed. * * Hooks that don't require the privilege won't see this input. * Hooks that do require it should treat the directory as * read-only — modifying it has no effect (the framework discards * changes after the hook returns). * * Layout: * / * index.json # { modules: [{ id, version, terraformStateDir }] } * modules/ * / * terraform/ * terraform.tfstate * terraform.tfstate.backup */ cross_module_root: { required: false, path: { access: 'read' } }, /** * Path to a directory holding copies of celilo's OWN state: a * WAL-correct `celilo.db` snapshot, `master.key`, the fleet `ssh/` * keypair, and `module_src//` for every module's lean source. * Populated by the framework under the same `cross_module_read` * allow-list as `cross_module_root`; only celilo-mgmt receives it. * * This input is what makes celilo's own backup possible WITHOUT * exempting celilo-mgmt from the hook jail (design D9b). The hook * never read those bytes — it copied them into `backup_dir` — so the * framework copies them into a directory it creates and hands over, * and the data directory itself stays out of every mount set. * * It replaces `db_path`, which `backup-create.ts` passed for months * while the contract declared nothing about it. That is the omission * the docblock on `ContractField.path` names: anything reasoning from * this table was wrong about what a backup hook receives, and a * mount-set derivation walking the declared inputs could not see it. */ system_state_root: { required: false, path: { access: 'read' } }, /** * The management-host inventory as a JSON array, staged by * `backup-create.ts` from `listMachines()` under the same * cross_module_read privilege as `system_state_root`. Not a path: the * hook reads the value, not a file, so it carries no mount-set entry. * * It replaces on_backup's `celilo machine list --json` spawn, which the * hook jail kills (no celilo binary, celilo#1225) and which never * actually produced JSON before that — the CLI printed its human report, * the hook's JSON.parse failed, and the catch wrote [] into every * envelope (ce-y0we). */ machine_pool: { required: false }, }, outputs: { artifact_count: { required: true }, size_bytes: { required: true }, schema_version: { required: true }, }, }, on_backup_analyze: { inputs: { artifact_path: { required: true, path: { access: 'read' } }, }, outputs: { artifact_count: { required: true }, size_bytes: { required: true }, schema_version: { required: true }, }, }, on_restore: { inputs: { restore_dir: { required: true, path: { access: 'read' } }, schema_version: { required: true }, /** * Path to a writable staging directory the framework atomically * applies back onto OTHER modules' on-disk state after the hook * returns successfully. Same allow-list as `cross_module_root` * on on_backup; only celilo-mgmt receives it today. * * Hooks write files matching the on_backup layout * (`modules//terraform/terraform.tfstate` etc.). The * framework moves each subdir into the live storage path with * a single rename + cleanup of any stale files, so a partial * write (hook crashed mid-restore) leaves the live state intact. */ cross_module_write_root: { required: false, path: { access: 'write' } }, }, outputs: { restored_items: { required: true }, }, }, /** * Per-system DNS lifecycle hook (openspec/specs/internal-dns-split-horizon/spec.md D5). * * A dns_internal provider declares this hook; celilo's internal-dns bridge * invokes it once per host when a system.created/destroyed event fires, * supplying that host's identity. The bridge owns the cross-module concerns * (which module is the provider, the full host inventory for backfill); the * hook owns only the DNS mechanics (register/deregister across the zones the * provider is authoritative for). No structured outputs — the hook throws on * failure (no-surprises; v2/issues/ISS-0004). */ on_system_event: { inputs: { /** Short hostname of the system being (de)registered, e.g. "www". */ hostname: { required: true }, /** The system's A-record target IP (no CIDR suffix), e.g. "10.0.10.10". */ target_ip: { required: true }, /** "register" on system.created, "deregister" on system.destroyed. */ op: { required: true }, }, outputs: {}, }, /** * Reconcile the provider's running config from a celilo registry when a * change event fires (ISS-0035). The caddy public_web provider declares this; * the dispatcher invokes it on `public_web.routes_changed` so caddy re-renders * its Caddyfile from web_routes (read via the injected web_routes view + * config). No required inputs — the event is a coarse "re-read the table" * signal. No structured outputs; throws on failure (no-surprises). */ reconcile_routes: { inputs: {}, outputs: {}, }, /** * Periodic re-assertion of a dns_registrar provider's registered * records (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). The * framework populates `registrations` from the dns_registrations * ledger (module scripts cannot read the celilo DB); the hook * re-sends each {fqdn, ip} to the underlying DNS API and fails — * loudly — if ANY record cannot be re-asserted. Typically driven by * a `timer.tick.15m` subscription. */ refresh_registrations: { inputs: { /** Array<{ fqdn: string; ip: string | null }>, framework-injected. */ registrations: { required: true }, }, outputs: {}, }, /** * Periodic re-assertion of the resolver a dns_internal provider hands * out over DHCP (celilo#739). Some routers regenerate that value from * their own upstream list on a timer and silently undo what on_install * set — the write succeeds and reads back correct, so only a client * renewing later ever sees the wrong resolver. No framework-injected * inputs: the hook derives the address it wants from its own config and * systems, and reads the device before writing. Typically driven by a * `timer.tick.1m` subscription, because the tick interval is the * worst-case window in which a renewing client can be handed the wrong * resolver for a full lease. */ reassert_dhcp_dns: { inputs: {}, outputs: {}, }, /** * Reconcile the clients a self-service app has enrolled against what the * VPN provider actually carries (openspec/changes/wireguard-manager, D1). * * The hook, rather than a bus `handler` subscription, because it needs * CAPABILITY INJECTION: it calls `control_plane_vpn.registerClient` and * `revokeClient`, and a handler gets no capabilities. No framework inputs — * it reads the app's address from its own `systems` and holds a token it * minted for itself at install. * * Typically driven by a `timer.tick.*` subscription, and the tick interval IS * the window in which a revoked device still has reach — which is why the * design requires the UI to show the pending state rather than imply * revocation is instant (D12). */ reconcile_clients: { inputs: {}, outputs: {}, }, /** * Report what a tunnel/interface is ACTUALLY carrying, diffed against what * celilo's config says it should carry. * * Deliberately a read of the live thing rather than of celilo's own record. * celilo#928 was exactly a divergence between the two — a device present in * config, rendered as active by a UI, and absent from the interface — and * diagnosing it needed a screenshot and a root SSH because nothing celilo * offered could tell the two apart. A hook that reported the record would * have reproduced the misleading signal rather than exposing it. * * No structured outputs: it reports through the logger, so an operator and an * agent read the same thing. */ list_peers: { inputs: {}, outputs: {}, }, /** * Re-assert a module's desired peer set onto the thing carrying it, on a * timer, without an operator. * * The counterpart to applying on change: apply-on-change bounds latency, this * bounds how long ANY divergence can persist — including drift nobody * predicted (a hand-edit on the box, an apply that failed, a record written * before the applying code existed). celilo#934, where the module could * already SEE the drift in `health_check` and had nowhere to act on it. * * A converge that changes nothing must be silent, so the log stays a record * of things happening. */ reconcile_peers: { inputs: {}, outputs: {}, }, /** * Reclaim disk a module's own store has accumulated and has no way to * release on its own. * * Distinct from `on_backup`'s retention, which prunes copies celilo itself * made and records. This is for state the module's SERVICE owns and celilo * never sees — a registry's package store, a cache, an artifact directory — * where the only thing that knows what is superseded is the service. * * Deliberately not a converge hook: convergence re-asserts a desired state * and is safe to run constantly, while this DELETES and its mistakes are not * recoverable. So it declares its retention policy in config, supports a dry * run, and is expected to be conservative — the celilo-registry sweep never * removes the last revision of a release, which is what makes running it * unattended defensible. * * Driven on demand (`celilo module run-hook sweep_revisions`) and, * where the store grows without an operator, by a `timer.tick.*` * subscription. */ sweep_revisions: { inputs: {}, outputs: {}, }, /** * Build-bus upstream publish hook (module-orchestrator-primitives slice 7). * An ordinary hook: the build-bus receiver daemon dispatches it through the * executor, jailed, with the verified PublishEvent as the `event` input in * the hook's typed context. It used to be a bash script reached by env vars * (`CELILO_EVENT_*`) on a second, unjailed execution path. * * `event` is a required input, which by the contract's own rule would be a * breaking change needing a v2.0 contract. No v2 is minted because no module * declares this hook as an invokable script: the only in-tree consumer was * celilo-mgmt's bash entry, deleted in the same slice, and the dispatcher * that invoked the old form never went through this table at all. The v1 * entry is being corrected to describe the shape the executor now actually * passes, in the same @celilo/cli major that changes the manifest field's * shape (tasks.md 7.6). */ on_upstream_publish: { inputs: { /** The verified PublishEvent, as received from the build bus. */ event: { required: true }, }, outputs: {}, }, }; /** * Contract v1.0 metadata. */ export const V1_CONTRACT = { version: '1.0' as const, hooks: V1_HOOKS, };