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
- Clean
/etc/zyginit/layout — each service self-contained inservices/<name>/with a canonicalservice.tomlplus optionalstart.sh/stop.sh. Eliminates the current ~50+ item flat directory mixing<name>.tomlfiles with per-service helper-script directories at the same level. tasktype made functional — currently the supervisor only branches ondaemonvsoneshot;transient(formerly the third constant) was a no-op alias fordaemon. 0.2.0 renamestransienttotaskand gives it real semantics: may run for a while, exit 0 = STOPPED (counts as online), exit ≠ 0 = FAILED, never restarts.[condition]block — declarative pre-flight checks. Services with failing conditions enter a new SKIPPED state instead of restart-looping or self-disabling viazygctl disable.- Migration tool —
scripts/migrate-layout.shconverts 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. Deferenv_set,virtualization,zone,kernel_version_min, etc. The minimal set covers acpihpd-style "platform doesn't support this" cases. - No new
zygctlsubcommands. Existing surface is sufficient. - No
service.tomlschema 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_stoppedfires, counted as online by the boot stats (per the existingSTATE_STOPPED + last_exit_code == 0 → onlinerule from 0.1.x). - On error exit (status ≠ 0): state transitions to FAILED,
ui_event_failedfires, counted as failed. - Never restarts. The
[restart]block, if present withon ≠ "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
commandbinary or exec failure: condition fails with reason"command not found"or"command exec failed". commandexit 0 = pass. Any non-zero exit = fail (reason includes the exit code).commandruntime 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 notestr.concat("skipped: ", reason). Callsemit_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:
- Discover top-level
<name>.tomlfiles in<config_dir>/. - For each, plan:
mkdir -p services/<name>/,mv <name>.toml services/<name>/service.toml. - If
<config_dir>/<name>/exists with helper scripts, plan:mv <name>/* services/<name>/. - For each
enabled.d/<name>symlink: plan retarget to../services/<name>/. - 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 (
mvwithin 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:
- Run the migration tool before swapping binaries:
/path/to/migrate-layout.sh --dry-run # preview /path/to/migrate-layout.sh --apply # apply - Then swap
/sbin/initand/sbin/zygctlas in prior 0.1.x deployments. - Reboot. If something goes wrong, the operator boots from a recovery medium and reverts
/sbin/initto the prior version (the new layout is forward-compatible-only with 0.1.x binaries on the original<name>.tomlflat 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 noservice.toml— warning, skip directory, continue.services/<name>/service.tomlparse 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 escapesservices/(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_fileto a missing file — condition fails, reason"missing /path".exists_file_anywith all missing — condition fails, reason"none of [paths] exist".commandbinary missing — condition fails, reason"command not found: /path".commandfork failure — condition fails, reason"command exec failed".commandexit non-zero — condition fails, reason"command exit=<code>".commandexceeds 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
mvwould 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.
tasktype 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. Updateservices/hammerhead/tree in this repo via the migration tool. - Phase 3.
tasktype — renameTRANSIENT→TASK, parser update, supervisor branch. - Phase 4.
[condition]block — schema,evaluate_conditions,STATE_SKIPPED(),skip_reason,ui_event_skipped,g_skippedcounter. - Phase 5. UI integration — banner counter, final-card summary,
zygctl statusskipped 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.