|
root / docs / superpowers / specs / 2026-05-14-zyginit-0.2.0-design.md
2026-05-14-zyginit-0.2.0-design.md markdown 360 lines 17.6 KB

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 toolscripts/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

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:

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:

[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:

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:
    /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 failsui_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 teststask_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 TRANSIENTTASK, 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.