# zyginit 0.2.0 — Design

**Date:** 2026-05-14
**Status:** Approved (brainstorm complete); implementation plan to follow
**Scope:** Three integrated changes shipped as a single MAJOR release (pre-1.0 MINOR per semver): per-service directory layout, `task` service type made functionally distinct from `daemon`, and a new `[condition]` block for declarative platform/runtime gating with a SKIPPED state.

## 1. Goals

1. **Clean `/etc/zyginit/` layout** — each service self-contained in `services/<name>/` with a canonical `service.toml` plus optional `start.sh`/`stop.sh`. Eliminates the current ~50+ item flat directory mixing `<name>.toml` files with per-service helper-script directories at the same level.
2. **`task` type made functional** — currently the supervisor only branches on `daemon` vs `oneshot`; `transient` (formerly the third constant) was a no-op alias for `daemon`. 0.2.0 renames `transient` to `task` and gives it real semantics: may run for a while, exit 0 = STOPPED (counts as online), exit ≠ 0 = FAILED, never restarts.
3. **`[condition]` block** — declarative pre-flight checks. Services with failing conditions enter a new SKIPPED state instead of restart-looping or self-disabling via `zygctl disable`.
4. **Migration tool** — `scripts/migrate-layout.sh` converts an existing 0.1.x `/etc/zyginit/` in place. Idempotent, dry-run-by-default. Required on every 0.1.x → 0.2.0 upgrade since 0.2.0 has no backward-compat reader.

## 2. Non-goals

- **No backward compat with 0.1.x layout in the daemon.** Hard-cut. zyginit 0.2.0 reads only the new layout; an unmigrated tree results in an empty boot with a loud fatal log.
- **No automatic upgrade-on-first-boot migration in PID 1.** Operators run the migration tool out of band before installing the 0.2.0 binary. PID-1 risk on a failed migration would be unacceptable.
- **No `[assert]` block** (systemd's "fail-don't-skip" variant). Defer to a future release if a real use case emerges.
- **No condition kinds beyond `exists_file`, `exists_file_any`, `command`.** Defer `env_set`, `virtualization`, `zone`, `kernel_version_min`, etc. The minimal set covers acpihpd-style "platform doesn't support this" cases.
- **No new `zygctl` subcommands.** Existing surface is sufficient.
- **No `service.toml` schema field for the service name.** Name comes from the directory name. One source of truth.

## 3. Version

**0.2.0.** Service-definition filesystem layout changing is an incompatible schema break per the project's versioning policy (`docs/superpowers/specs/2026-05-05-versioning-and-release-tarball-design.md`). Pre-1.0 MINOR is permitted to break per semver convention. Tag `v0.2.0` points at the release commit.

## 4. Filesystem layout

### 4.1 New canonical layout

```
/etc/zyginit/
├── services/
│   ├── <name>/
│   │   ├── service.toml          # canonical filename inside every dir
│   │   ├── start.sh              # optional, executable
│   │   └── stop.sh               # optional, executable
│   └── ... (one directory per service)
├── enabled.d/
│   └── <name> -> ../services/<name>/   # symlink targets the directory
├── examples/
│   └── <name>/
│       └── service.toml
└── zyginit.toml                   # reserved for future; optional
```

### 4.2 Symlink target

`enabled.d/<name>` symlinks point at `../services/<name>/` — the **directory**, not the `service.toml` inside it. Rationale:
- Enables future per-service drop-in overrides (a `services/<name>/drop-ins/` directory or similar) without changing the enable mechanism.
- One symlink per service regardless of how many files live inside.
- Consistent with the "service = directory" mental model.

### 4.3 Path resolution within a service

Paths in `service.toml` that begin with `./` resolve relative to the service's directory. So `exec.start = "./start.sh"` means `services/<name>/start.sh`. Absolute paths (`/usr/sbin/dlmgmtd`) are unchanged.

## 5. `task` type

### 5.1 Name change

The constant `SERVICE_TYPE_TRANSIENT()` is renamed to `SERVICE_TYPE_TASK()` in `src/config.reef`. Integer value is preserved (the constant was unused in any on-disk state). `service_type_name(SERVICE_TYPE_TASK())` returns `"task"`. The TOML parser accepts `type = "task"` and rejects `type = "transient"` with a clear error.

### 5.2 Semantics

A `task` service:
- May run for any duration (unlike `oneshot`, which the operator expects to exit quickly).
- Is supervised normally during its run (process contract, state RUNNING, etc.).
- On clean exit (status 0): state transitions to STOPPED, `ui_event_stopped` fires, counted as **online** by the boot stats (per the existing `STATE_STOPPED + last_exit_code == 0 → online` rule from 0.1.x).
- On error exit (status ≠ 0): state transitions to FAILED, `ui_event_failed` fires, counted as **failed**.
- **Never restarts.** The `[restart]` block, if present with `on ≠ "never"`, is ignored at runtime with a config-load-time warning.
- **Skips the daemonize handshake.** Tasks don't double-fork; the entering-RUNNING-then-exiting pattern is the task's normal completion, not a daemon ready signal.

### 5.3 Supervisor branch

`src/supervisor.reef:handle_contract_event`, inserted right after the exit-code resolution and **before** the existing daemonize-handshake check:

```reef
let svc_type = config.svc_type(def)

if svc_type == config.SERVICE_TYPE_TASK()
    let dur_ms = (time.time_now() - rt.last_start_time) * 1000
    let ec = if exit_code >= 0 then exit_code else 0 end if
    rt.pid = 0 - 1
    rt.contract_id = 0 - 1
    rt.last_exit_code = ec
    table.contract_map.remove(int_to_str(contract_id))
    if ec == 0
        rt.state = STATE_STOPPED()
        table.runtimes[idx] = rt
        ui.ui_event_stopped(name, dur_ms)
    else
        rt.state = STATE_FAILED()
        table.runtimes[idx] = rt
        ui.ui_event_failed(name, ec, dur_ms)
    end if
    return
end if
```

A `task` whose contract empties without a known exit code (the `hint_exit_code == -1` path) is best-effort treated as `exit 0` — the contract emptied cleanly and we have no signal of failure.

## 6. `[condition]` block

### 6.1 Schema

Optional block in `service.toml`:

```toml
[condition]
exists_file     = "/dev/acpihp"
exists_file_any = ["/dev/acpi", "/dev/acpihp"]
command         = "/usr/sbin/acpi_probe"
```

**Semantics:**
- All specified keys must pass (AND).
- Absent keys are no-ops.
- `exists_file_any = []` (empty list) is vacuously true.
- Missing `command` binary or exec failure: condition fails with reason `"command not found"` or `"command exec failed"`.
- `command` exit 0 = pass. Any non-zero exit = fail (reason includes the exit code).
- `command` runtime is bounded by a **5-second timeout**. Exceeded: kill the probe, condition fails with reason `"command timeout"`.

### 6.2 Evaluation timing

Conditions evaluate in `supervisor.start_service`, **before** the fork:

```reef
proc start_service(table: ServiceTable, idx: int): bool
    let rt = table.runtimes[idx]
    let def = rt.def
    let name = config.svc_name(def)

    let cond_result = evaluate_conditions(def)
    if cond_result.passed == false
        rt.state = STATE_SKIPPED()
        rt.skip_reason = cond_result.reason
        table.runtimes[idx] = rt
        ui.ui_event_skipped(name, cond_result.reason)
        return true
    end if

    // existing fork + exec path
    ...
end start_service
```

Conditions re-evaluate on every call to `start_service`. So `zygctl restart acpihpd` on a host where the condition still fails produces a fresh SKIPPED. A service SKIPPED on boot N can become RUNNING on boot N+1 if its condition becomes true.

### 6.3 Implementation location

`evaluate_conditions(def: ServiceDef): ConditionResult` lives in `src/config.reef` (or a new `src/condition.reef` if it grows beyond ~80 lines). Returns `{passed: bool, reason: string}` where reason is the human-readable cause.

### 6.4 New state

`STATE_SKIPPED()` is added to the runtime state enum in `src/supervisor.reef`. New field `skip_reason: string` on `ServiceRuntime`. State display:

| State | Glyph | Color | Label |
|-------|-------|-------|-------|
| `STATE_SKIPPED()` | `·` (Unicode) / `.` (ASCII) | `c.mute` | `" skipped"` |

The glyph is the same as STATE_STOPPED's; the label differs (`" skipped"` vs `" stopped"`). The reason text appears in the tape note and `zygctl status` NOTES column.

## 7. UI integration

### 7.1 New banner counter

`g_skipped: int` global in `ui.reef`. Reset in `ui_boot_start`. Incremented by `ui_event_skipped`. Shown in the boot/shutdown header summary line:

```
23 services · tier 5 · 19 done · 0 failed · 4 skipped
```

The boot final card:

```
[Z] zyginit 0.2.0 · multi-user                       boot 8.3s
23 services — 19 online, 0 failed, 4 skipped

all services online
─────────────────────────────────────
slowest:  dlmgmtd 1.2s  filesystem 0.55s  sshd 0.37s
```

When skipped > 0 and failed > 0: existing "failed: ..." line above the divider remains; skipped count appears in the summary headline.

### 7.2 New event proc

`ui_event_skipped(name: string, reason: string)`:
- Increments `g_skipped`.
- In rich mode: pushes a tape row with glyph `paint("mute", glyph_pending())`, name, and note `str.concat("skipped: ", reason)`. Calls `emit_redraw`.
- In plain mode: emits `<elapsed-s>  info  skipped  <name>  reason=<reason>`.

### 7.3 Progress bar

Skipped services count toward the **denominator advance** so the bar reaches 100% naturally:

```
counter = g_done + g_skipped    (where g_done already includes failed)
total   = g_num_svc
```

The label appended to the bar in shutdown mode (`" down"`) stays unchanged. In boot mode the bar's right-side counter reads `<counter>/<total>  <percent>%`.

### 7.4 `zygctl status`

Skipped services render in the table:

```
acpihpd       · skipped                              no /dev/acpihp
```

State column shows the dim glyph and `" skipped"` label. NOTES column shows the reason. Banner summary line includes the skipped count.

### 7.5 `zygctl list`

Skipped state isn't a "list" property (the list is the catalog, not the runtime state). `zygctl list` output is unchanged.

## 8. Migration

### 8.1 Tool

`scripts/migrate-layout.sh` is a portable POSIX shell script delivered in the release tarball.

**Modes:**
- `--dry-run` (default): prints the planned moves to stdout and exits 0.
- `--apply`: performs the moves.
- `--config-dir <path>`: target directory (default `/etc/zyginit/`).

**Operation:**
1. Discover top-level `<name>.toml` files in `<config_dir>/`.
2. For each, plan: `mkdir -p services/<name>/`, `mv <name>.toml services/<name>/service.toml`.
3. If `<config_dir>/<name>/` exists with helper scripts, plan: `mv <name>/* services/<name>/`.
4. For each `enabled.d/<name>` symlink: plan retarget to `../services/<name>/`.
5. Idempotent: skips any service that's already migrated (already has `services/<name>/service.toml`).

**Safety:**
- If both old (`<name>.toml`) and new (`services/<name>/service.toml`) layouts exist for the same service, refuse to proceed. Operator must resolve the ambiguity manually.
- All moves are atomic (`mv` within the same filesystem).
- The script is idempotent; safe to re-run.

### 8.2 In-repo `services/hammerhead/` tree

Gets the same migration before the 0.2.0 release tarball is built. The tree currently has 17 services in the old flat layout; the migration is a one-shot reshape in a single commit during implementation.

The `examples/` directory follows the same convention: each example becomes `examples/<name>/service.toml`. The migration tool walks `examples/` too. Operators copying an example into a real config can do a flat directory copy without renaming files.

### 8.3 Hammerhead-side overrides

`base/usr/src/zyginit/overrides/` on the Hammerhead side currently uses the same flat layout. The Hammerhead team runs the migration tool against their override tree as part of consuming the 0.2.0 source drop. Release notes call this out.

### 8.4 Release-note guidance

The 0.2.0 release notes include:

1. **Run the migration tool before swapping binaries:**
   ```sh
   /path/to/migrate-layout.sh --dry-run    # preview
   /path/to/migrate-layout.sh --apply      # apply
   ```
2. **Then swap `/sbin/init` and `/sbin/zygctl`** as in prior 0.1.x deployments.
3. **Reboot.** If something goes wrong, the operator boots from a recovery medium and reverts `/sbin/init` to the prior version (the new layout is forward-compatible-only with 0.1.x binaries on the original `<name>.toml` flat tree — operator would need to reverse the migration too).

## 9. Data flow (boot)

```
main.reef on PID 1 boot:
  setup_pid1_console()
  ui_init(detect_rich_mode_for_main())
  ui_detect_winsize()
  if !exists(<config_dir>/services/)
    println FATAL: services/ directory missing — run scripts/migrate-layout.sh
    enter idle loop
  end if
  load_services_from_layout()         // walks services/*/service.toml
  scan_enabled_dir(enabled.d/)        // dereferences directory symlinks
  build_dep_graph(...)
  ui_boot_start(num_svc, num_tiers, runlevel)
  for each tier:
    ui_tier_start(tier)
    for each service in tier:
      supervisor.start_service(...)
        // condition evaluation BEFORE fork
        evaluate_conditions(def)
        if !passed:
          state = SKIPPED
          ui_event_skipped(name, reason)
          continue
        // type dispatch
        if type == TASK:
          fork+exec; on exit handle via task branch (see §5.3)
        elif type == DAEMON:
          fork+exec; daemonize handshake path as today
        elif type == ONESHOT:
          fork+exec; oneshot completion path as today
    poll-and-tick until tier settles
    ui_tier_done(tier)
  boot_settle_period (1s, from 0.1.8)
  build_boot_stats(table) → includes online/failed/skipped/slowest
  ui_boot_complete(stats)
  enter main event loop
```

## 10. Error handling

### 10.1 Layout discovery

- **`services/<name>/` exists but no `service.toml`** — warning, skip directory, continue.
- **`services/<name>/service.toml` parse error** — fatal-tier log, omit from table, continue with other services.
- **`enabled.d/<name>` symlink target doesn't exist** — warning, ignore, continue.
- **`enabled.d/<name>` symlink target escapes `services/`** (target path doesn't start with `<config_dir>/services/`) — warning, ignore, continue. Defensive: prevents arbitrary-path read via a malicious enabled.d symlink.
- **No `services/` directory at all** — fatal log, empty boot, idle loop (operator must migrate).

### 10.2 Condition evaluation

- **`exists_file` to a missing file** — condition fails, reason `"missing /path"`.
- **`exists_file_any` with all missing** — condition fails, reason `"none of [paths] exist"`.
- **`command` binary missing** — condition fails, reason `"command not found: /path"`.
- **`command` fork failure** — condition fails, reason `"command exec failed"`.
- **`command` exit non-zero** — condition fails, reason `"command exit=<code>"`.
- **`command` exceeds 5-second timeout** — kill probe, condition fails, reason `"command timeout"`.

### 10.3 Task handling

- **Task fork fails** — `ui_event_failed`, no restart attempt. Same as daemon fork failure.
- **Task contract empties with `exit_code = -1`** — treat as exit 0 (best-effort), state STOPPED.
- **Task with `[restart] on = "always"`** — config-load-time warning logged; supervisor still doesn't restart it.

### 10.4 Migration tool

- **Both old and new layouts exist for the same service** — refuse to proceed, exit non-zero with clear message.
- **Cross-filesystem `mv` would be required** — refuse, advise operator to ensure `<config_dir>` is on a single filesystem.
- **Permission denied** — exit non-zero with the offending path.

## 11. Testing

See Brainstorm Section 6 in the chat history for the full test matrix. Highlights:

- **Layout parser unit tests** — name resolution from directory, parse-error containment, skip-directory-without-toml.
- **Symlink resolution tests** — happy path, dangling, escape rejection.
- **Migration tool tests** — dry-run, apply, idempotent, ambiguity rejection.
- **`task` type tests** — exit 0 → STOPPED+online, exit 1 → FAILED, no restart loop, fork failure handling.
- **`[condition]` block tests** — each kind in pass and fail, command timeout, binary missing, multi-kind AND.
- **UI snapshot tests** — `task_ok`, `task_failed`, `boot_with_skipped`, `final_card_with_skipped`, `status_skipped`.
- **Hammerhead VM smoke test** — manual on `hh-prototest`, includes running the migration tool against the live config before swapping binaries.

## 12. Phased delivery (preview for implementation plan)

The plan will likely break into ~6-8 phases:

- **Phase 1.** Migration tool (`scripts/migrate-layout.sh`) with dry-run + apply + idempotent + ambiguity refusal. Plus Linux-dev unit tests for the tool against fixtures.
- **Phase 2.** Layout parser changes — `scan_services_dir`, symlink-target dereference, escape rejection. Update `services/hammerhead/` tree in this repo via the migration tool.
- **Phase 3.** `task` type — rename `TRANSIENT` → `TASK`, parser update, supervisor branch.
- **Phase 4.** `[condition]` block — schema, `evaluate_conditions`, `STATE_SKIPPED()`, `skip_reason`, `ui_event_skipped`, `g_skipped` counter.
- **Phase 5.** UI integration — banner counter, final-card summary, `zygctl status` skipped row, progress-bar denominator update.
- **Phase 6.** Integration tests + snapshot tests for all the new scenarios.
- **Phase 7.** Version bump 0.1.8 → 0.2.0, tag, release tarball, release-note guidance for Hammerhead consumers.

Each phase ships as one or more focused commits; all tests must pass at the end of each phase before moving to the next.
