Design: conflicts — service mutual exclusion
Date: 2026-06-13
Status: Approved (design phase)
Roadmap item: Near Term — Linux Development → Dependency Ordering Improvements → conflicts
Summary
Add a conflicts field to a service's [dependencies] block that declares two
services as mutually exclusive — they may never run at the same time. This is the
inverse of requires: where requires pulls a dependency in, conflicts keeps
a peer out.
Motivating example: two services that bind the same resource (e.g. sendmail vs
postfix on port 25) should never both be running.
The feature is fully developable and testable in Linux dev mode — it touches the
config parser, the supervisor start path, and a new boot-time pre-flight pass. It
deliberately does not touch depgraph.reef: a conflict is mutual exclusion,
not an ordering relation, so it never becomes a topo-sort edge.
Semantics (decided)
| Question | Decision |
|---|---|
Runtime: zygctl start B while conflicting A is running |
Refuse. Fail the start with a clear message; leave A running. No auto-eviction. |
| Boot: both conflicting services enabled and in the active set | Start neither; mark both FAILED with reason "conflicts with <other>". zyginit never auto-picks a winner. |
| Declaration symmetry | Symmetric. Declaring conflicts on either side makes the pair mutually exclusive in both directions. |
Cascade (accepted consequence): a service that requires a conflict-failed
service will itself fail to start (existing behavior — a required dependency that
is not running blocks its dependents). For a deterministic init this is correct:
fail loudly and visibly rather than limp along on an arbitrary choice.
Architecture
The single most important code fact: every service start flows through
supervisor.start_service(table, idx) (src/supervisor.reef:327) — boot tiers
(main.reef:start_services_by_tier), zygctl start (socket.reef:cmd_start),
runlevel transitions, and restarts all call it. That gives us one choke point for
the runtime guard, and a separate one-shot pre-flight for the boot case.
Two enforcement points, cleanly scoped and non-overlapping:
- Boot-time resolution (
resolve_conflicts) — a pre-flight pass that runs
once, before the tier-start loop. Detects pairs where both members are active
and fails both up front. - Runtime guard — inside
start_service, refuses to start a service when a
conflicting peer is alreadyRUNNINGorSTARTING.
After the pre-flight runs at boot, no conflicting pair both survive into the active
set, so the runtime guard never trips spuriously during boot. The two mechanisms
do not overlap.
Component 1: Schema & parsing (src/config.reef)
- Add to
ServiceDef:conflicts: [string]conflicts_count: int
new_service_def()initializesconflicts: new [string](16),conflicts_count: 0
(mirrorsrequires).- Accessors
svc_conflicts(svc): [string]andsvc_conflicts_count(svc): int. - Parse branch mirroring the
dependencies.requiresblock atconfig.reef:606:if toml.toml_has_key(keys, vals, count, "dependencies.conflicts") let conf_str = toml.toml_get(keys, vals, count, "dependencies.conflicts") svc.conflicts_count = parse_toml_array(conf_str, svc.conflicts, 16) end if
Component 2: The symmetric relation (helper)
A single helper expresses the mutual relation so both enforcement points agree:
fn conflicts_with(table, a_idx, b_idx): bool
Returns true if a lists b in its conflicts, or b lists a in its
conflicts (symmetry). Comparison is by service name. Self-reference
(a == b) returns false. Names that do not resolve to a known service are
ignored (warned once during the pre-flight pass, same spirit as
depgraph's "requires unknown service" warning).
Lives in supervisor.reef (it operates over the runtime table). Both
resolve_conflicts and the start_service guard call it.
Component 3: Boot-time resolution (resolve_conflicts)
- New proc in
supervisor.reef, invoked frommain.reefimmediately before
start_services_by_tier. - For each unordered pair
(i, j)of services that are both in the active set
andconflicts_with(table, i, j): set bothruntimes[i]andruntimes[j]to
STATE_FAILED()with a stored reason"conflicts with <other-name>". - A service already failed by an earlier pair stays failed (three-way mutual
conflicts → all involved fail). Order-independent. - The tier-start loop must skip services that are not in a startable state
(already-FAILED/SKIPPEDservices are not forked). Verify
start_services_by_tierhonors this; if it currently starts unconditionally,
add the state guard.
Component 4: Runtime guard (inside start_service)
- Insert immediately after the
[condition]evaluation block
(supervisor.reef:341), before contract template setup / fork. - Scan the table: if any service
jwithconflicts_with(table, idx, j)is in
STATE_RUNNING()orSTATE_STARTING(), abort:- do not change
idx's state (leave itSTOPPED/WAITING), - surface
"cannot start <name>: conflicts with running service <other>"
(println in plain mode; returned to the socket caller forzygctl), return false.
- do not change
- Because all start paths funnel through
start_service, this coverszygctl startand runlevel transitions with no extra code at those call sites.
Component 5: Reporting
- The failure/refusal reason is stored on the service runtime and shown in
zygctl status. Reuse the existing status-reason mechanism rather than adding
a parallel field — the exact field name (skip_reasonvs. a new
status_reason) is confirmed against theServiceRuntimestruct during
implementation; ifskip_reasonisSKIPPED-specific, a small rename to a
generic reason field is acceptable and preferred over a second parallel field. - Boot case: two
FAILEDrows, each readingconflicts with <other>. - Runtime case: the refusal message is returned on the
zygctl startconnection;
the target service's status is unchanged.
Error handling & edge cases
- Unknown conflict target (names a service that doesn't exist): warn once
duringresolve_conflicts, then ignore. Never fatal. - Self-conflict (
AlistsA): ignored. - Duplicate / both-sided declaration (
AlistsBandBlistsA):
harmless — the symmetric helper already treats it as one relation. - Three+ way mutual conflict: every service involved in any active conflicting
pair is failed. Deterministic and order-independent. - Conflict with a disabled / non-active service: no effect — only services in
the active set participate in boot resolution, and onlyRUNNING/STARTING
peers trip the runtime guard.
Testing (Linux dev mode, tests/integration/)
New suite plus a parser check:
- Boot resolution: enable two services that conflict; assert both end in
FAILEDwith aconflicts withreason, and neither process ran. - Runtime refusal: start
A;zygctl start B; assert the refusal message,
thatBdid not start, and thatAis stillRUNNING. - Symmetry: declare
conflictson one side only; assert enforcement holds
regardless of which service starts first. - Parser (
config.reeftest path): a TOML with
[dependencies] conflicts = ["x", "y"]parses toconflicts_count == 2with
the right names.
Documentation
docs/DESIGN.md— addconflictsto the[dependencies]schema reference.zyginit.5man page — document the field and its three semantics.CLAUDE.md— addconflictsto the[dependencies]block in the Service
Definition Schema quick reference.ROADMAP.md— check theconflictsbox under Dependency Ordering Improvements.
No new file added to examples/ — the 4 canonical examples stay as-is; the
schema docs carry the conflicts reference.
Out of scope (YAGNI)
- systemd-style auto-eviction (stopping the running peer to start a new one) —
explicitly rejected in favor of "refuse." - Conflicts as graph edges / ordering — a conflict is exclusion, not order.
conflictsparticipating in dependency resolution or milestones.