|
root / docs / superpowers / specs / 2026-06-13-conflicts-design.md
2026-06-13-conflicts-design.md markdown 171 lines 8.2 KB

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:

  1. 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.
  2. Runtime guard — inside start_service, refuses to start a service when a
    conflicting peer is already RUNNING or STARTING.

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() initializes conflicts: new [string](16), conflicts_count: 0
    (mirrors requires).
  • Accessors svc_conflicts(svc): [string] and svc_conflicts_count(svc): int.
  • Parse branch mirroring the dependencies.requires block at config.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 from main.reef immediately before
    start_services_by_tier.
  • For each unordered pair (i, j) of services that are both in the active set
    and conflicts_with(table, i, j): set both runtimes[i] and runtimes[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/SKIPPED services are not forked). Verify
    start_services_by_tier honors 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 j with conflicts_with(table, idx, j) is in
    STATE_RUNNING() or STATE_STARTING(), abort:
    • do not change idx's state (leave it STOPPED/WAITING),
    • surface "cannot start <name>: conflicts with running service <other>"
      (println in plain mode; returned to the socket caller for zygctl),
    • return false.
  • Because all start paths funnel through start_service, this covers zygctl start and 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_reason vs. a new
    status_reason) is confirmed against the ServiceRuntime struct during
    implementation; if skip_reason is SKIPPED-specific, a small rename to a
    generic reason field is acceptable and preferred over a second parallel field.
  • Boot case: two FAILED rows, each reading conflicts with <other>.
  • Runtime case: the refusal message is returned on the zygctl start connection;
    the target service's status is unchanged.

Error handling & edge cases

  • Unknown conflict target (names a service that doesn't exist): warn once
    during resolve_conflicts, then ignore. Never fatal.
  • Self-conflict (A lists A): ignored.
  • Duplicate / both-sided declaration (A lists B and B lists A):
    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 only RUNNING/STARTING
    peers trip the runtime guard.

Testing (Linux dev mode, tests/integration/)

New suite plus a parser check:

  1. Boot resolution: enable two services that conflict; assert both end in
    FAILED with a conflicts with reason, and neither process ran.
  2. Runtime refusal: start A; zygctl start B; assert the refusal message,
    that B did not start, and that A is still RUNNING.
  3. Symmetry: declare conflicts on one side only; assert enforcement holds
    regardless of which service starts first.
  4. Parser (config.reef test path): a TOML with
    [dependencies] conflicts = ["x", "y"] parses to conflicts_count == 2 with
    the right names.

Documentation

  • docs/DESIGN.md — add conflicts to the [dependencies] schema reference.
  • zyginit.5 man page — document the field and its three semantics.
  • CLAUDE.md — add conflicts to the [dependencies] block in the Service
    Definition Schema quick reference.
  • ROADMAP.md — check the conflicts box 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.
  • conflicts participating in dependency resolution or milestones.