# 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.
