Link

The otto.link package models connectivity between lab hosts as one Link type regardless of where it came from: implicit SSH/telnet hop edges and declared lab.json routes. otto.link.derive resolves those edges at lab-load time. A link is a topology edge — the route that exists; the live, host-resident tunnels built over it are otto.tunnel’s concern (see the tunnel guide and API reference).

Impairment builds on that same edge model: otto.link.params is the typed parameter set and its unit/merge rules, otto.link.placement resolves where one direction’s netem lands (endpoint or in-path middlebox), otto.link.impairer is the pluggable LinkImpairer registry (otto.link.netem the first-party NetEm registrant), otto.link.manage is the merge-read-modify-verify orchestration behind otto link impair/repair/list, and otto.link.sentinel tags the detached --expire timer processes. See the link guide for CLI usage, the in-path model, and the Python API.

Model, static derivation, and id computation for the static link layer (implicit hop edges, declared routes). Live tunnel creation/discovery lives in otto.tunnel.

otto.link.canonical_key(params: ImpairmentParams) tuple[int | None, ...]

Spelling-independent identity for params — compare MEANING, not text.

tc canonicalizes on display, so the exact string otto applies is NOT the string it reads back: 1.5mbit->``1500Kbit``, 10mbps->``80Mbit`` (bytes to bits, times 8), >=1s in %g seconds. A post-apply verify (and any equality that must survive a kernel round-trip) therefore has to canonicalize both sides first, or it false-fails on documented inputs. Times collapse to integer microseconds, percents to integer milli-percent, rate to integer bits-per-second via the _RATE_UNITS_BPS multiplier map; round scrubs float dust (700 * 0.001 == 0.7000000000000001) — final-review Critical 2026-07-10.

Whole-millisecond delay/jitter values are tick-aligned (netem quantizes to 64ns psched ticks, and 1ms = 1,000,000ns divides evenly by 64) and round-trip through this key exactly. Sub-millisecond values do NOT: 0.7ms is the QUANTIZATION example, not a round-trip-stable one — on real hardware it reads back as 699us, a genuine ~1us kernel-tick delta rather than a spelling reformat (observed live, 2026-07-10). This key still records that delta exactly (it must — it is the ONE place meaning is captured); equivalent() is where the tick-quantization tolerance for the delay/jitter slots lives.

otto.link.equivalent(a: ImpairmentParams, b: ImpairmentParams) bool

Return True when a and b mean the same impairment (see canonical_key()).

The delay/jitter (TIME) slots of the canonical key compare with a small tolerance instead of exact equality; every other slot (percent, rate) still compares exactly. WHY: netem quantizes delay/jitter to 64ns kernel (psched) ticks and tc’s display is microsecond-truncated, so a value like 700us can read back as 699us on real hardware — a genuine kernel-tick artifact, observed live 2026-07-10, not a spelling reformat (canonical_key() already handles those). The tolerance is max(2us, 0.5% of the larger value): the 2us floor is 2x the observed ~1us quantization delta, and the 0.5% relative band covers tc’s %g display rounding at second scale (see the second-scale cases in TestTickQuantizationTolerance).

This cannot mask a real mismatch: an impairment that genuinely failed to apply (or applied as a different value) reads back as None (field missing), the PRIOR value (merge never took), or a different field entirely — never a same-field sub-percent delta on the value that WAS actually applied. None is only equivalent to None.

Runtime Link model — the unified edge object across all provenances.

One type regardless of where the link came from, so the CLI, topology derivation, and monitor GUI all speak the same object (foundation spec §6).

class otto.link.model.Provenance(value)

Bases: Enum

Where a link came from.

IMPLICIT = 'implicit'

Derived from a host’s hop chain (the ssh/telnet management path).

DECLARED = 'declared'

Declared in lab.json’s links section (a data-plane route).

DYNAMIC = 'dynamic'

Realized by otto tunnels; reserved for topology overlays.

class otto.link.model.LinkEndpoint(host: str, interface: str | None = None, ip: str = '', port: int | None = None)

Bases: object

One end of a link: a host, optionally pinned to a named interface.

host : str

Host id (see make_host_id).

interface : str | None

Netdev name (a key in the host’s interfaces map); None = the management ip / the host’s sole interface.

ip : str

Resolved address of this end (empty when unresolvable, e.g. a sentinel parsed without lab context).

port : int | None

Reserved/observed-port metadata for this end.

Deterministic id for the route a <-> b over protocol.

STABILITY CONTRACT — changing this algorithm invalidates every recorded static route id across otto versions:

  • endpoints are sorted by (host, interface or "") so a<->b == b<->a;

  • a None interface falls back to '' in the canonical string;

  • protocol is lowercased, so a route declared "udp" and one declared "UDP" reconcile to the same id;

  • ports and ips are excluded — the id names the route, not any one observation of it;

  • format: "lnk-" + first 12 hex chars of sha256 over "{lo.host}|{lo.interface or ''}|{hi.host}|{hi.interface or ''}|{protocol.lower()}".

Readable handle for a static link — the declared name or a--b.

No hash: static links (implicit hop edges, declared routes) are described connectivity, not otto tunnels, so they never wear the lnk-<hex> form. Endpoints are sorted so a<->b and b<->a yield the same handle.

Bases: object

An edge between two endpoints, from any provenance.

a : LinkEndpoint
b : LinkEndpoint
protocol : str
provenance : Provenance
id : str

Static id, auto-computed when empty via make_static_link_id (readable name/a--b handle).

name : str | None

Optional friendly handle from the lab data.

impair : str | None

In-path middlebox host id servicing this link’s impairment (spec §10); None = endpoint-anchored impairment.

Pure derivations of the static link layer (implicit hop edges + declared links).

No I/O and no live host access: callers hand in host dicts / host objects, these functions hand back Link objects. That keeps every rule here unit-testable without a lab.

class otto.link.derive.HostAddressing(ip: str, interfaces: dict[str, str] = <factory>)

Bases: object

The minimal addressing view of a host a link endpoint needs.

ip : str
interfaces : dict[str, str]

Interface name -> address (values already flattened to strings).

otto.link.derive.addressing_from_dict(host_data: dict[str, Any]) tuple[str, HostAddressing]

(host_id, HostAddressing) from a raw lab.json host dict.

Applies the interface string-shorthand (a bare string value is the ip), mirroring InterfaceSpec’s coercion — this reads raw dicts so cross-lab (dangling) endpoints resolve without constructing hosts.

Validate + resolve raw links entries into DECLARED Link objects.

source names the origin (a file path or “lab.json”) for error messages. loaded_ids is the id set of the requested lab’s hosts; link_data spans ALL lab files (so cross-lab dangling endpoints resolve), so an entry whose both endpoints lie outside loaded_ids belongs to an unrelated lab and is skipped — its errors (unknown host, bad interface, or even a structurally malformed record) must not break this lab’s load, symmetric with the cross-lab host-record containment in JsonFileLabRepository. An entry touching the lab (≥1 endpoint in loaded_ids) is resolved and fails loud, naming the entry’s original file index and source.

IMPLICIT edges from hop chains, rooted at the built-in local host.

Duck-typed on purpose (reads id/ip/hop/term): callers pass lab.hosts, tests pass stand-ins. A host with a hop edges to its hop host; a hop-less host edges to local (the “you are here” root — the monitor’s reachability cascade needs the full chain back to local). Protocol = the child’s management term (ssh/telnet).

Typed impairment parameters: unit parsing, merge, and coupling rules.

Spec §3.1/§3.3 (docs/superpowers/specs/2026-07-10-link-impairment-design.md): bare time = milliseconds, bare percent = percent, rate REQUIRES an explicit tc unit; re-impair merges per-param last-one-wins and an explicit zero clears just that param.

otto.link.params.parse_time_ms(text: str, *, option: str) float

Parse a time value in milliseconds; a bare number means ms (spec §3.1).

otto.link.params.parse_percent(text: str, *, option: str) float

Parse a percentage; a bare number means percent (spec §3.1).

otto.link.params.parse_rate(text: str) str

Parse a rate; an explicit tc unit is REQUIRED. Bare "0" clears (§3.3).

class otto.link.params.ImpairmentParams(delay_ms: float | None = None, jitter_ms: float | None = None, loss_pct: float | None = None, corrupt_pct: float | None = None, duplicate_pct: float | None = None, reorder_pct: float | None = None, rate: str | None = None)

Bases: object

One impairment parameter set. None = not set/absent.

delay_ms : float | None
jitter_ms : float | None
loss_pct : float | None
corrupt_pct : float | None
duplicate_pct : float | None
reorder_pct : float | None
rate : str | None

Canonical lowercase tc rate string ("10mbit"); "0" = clear.

is_empty() bool

Return True if every param is unset.

merged_over(base: ImpairmentParams) ImpairmentParams

Per-param last-one-wins over base; explicit zeros clear (spec §3.3).

validate() None

Enforce netem coupling rules.

Evaluate AFTER merge — a jitter or reorder given now may be joining a delay that was already applied in an earlier impair call.

describe() str

Human/argv-shaped summary with explicit units, e.g. delay 50ms loss 2%.

class otto.link.params.Selector(port: int, proto: str | None = None)

Bases: object

One port-scoped impairment selector: a service port, EITHER side.

Matches traffic whose SOURCE OR DESTINATION port is port — otto never needs to know which endpoint is the server. proto narrows to one L4 protocol; None = both tcp and udp. Selector(5201) and Selector(5201, "tcp") are DISTINCT keys; the former’s filters simply match a superset of the latter’s traffic (spec 2026-07-11 §1).

port : int
proto : str | None

"tcp", "udp", or None = both.

describe() str

Return the one string form for the selector.

The form (5201 / 5201/tcp) is used uniformly by the CLI, list rows, error text, and the v2 sentinel payload.

otto.link.params.canonical_key(params: ImpairmentParams) tuple[int | None, ...]

Spelling-independent identity for params — compare MEANING, not text.

tc canonicalizes on display, so the exact string otto applies is NOT the string it reads back: 1.5mbit->``1500Kbit``, 10mbps->``80Mbit`` (bytes to bits, times 8), >=1s in %g seconds. A post-apply verify (and any equality that must survive a kernel round-trip) therefore has to canonicalize both sides first, or it false-fails on documented inputs. Times collapse to integer microseconds, percents to integer milli-percent, rate to integer bits-per-second via the _RATE_UNITS_BPS multiplier map; round scrubs float dust (700 * 0.001 == 0.7000000000000001) — final-review Critical 2026-07-10.

Whole-millisecond delay/jitter values are tick-aligned (netem quantizes to 64ns psched ticks, and 1ms = 1,000,000ns divides evenly by 64) and round-trip through this key exactly. Sub-millisecond values do NOT: 0.7ms is the QUANTIZATION example, not a round-trip-stable one — on real hardware it reads back as 699us, a genuine ~1us kernel-tick delta rather than a spelling reformat (observed live, 2026-07-10). This key still records that delta exactly (it must — it is the ONE place meaning is captured); equivalent() is where the tick-quantization tolerance for the delay/jitter slots lives.

otto.link.params.equivalent(a: ImpairmentParams, b: ImpairmentParams) bool

Return True when a and b mean the same impairment (see canonical_key()).

The delay/jitter (TIME) slots of the canonical key compare with a small tolerance instead of exact equality; every other slot (percent, rate) still compares exactly. WHY: netem quantizes delay/jitter to 64ns kernel (psched) ticks and tc’s display is microsecond-truncated, so a value like 700us can read back as 699us on real hardware — a genuine kernel-tick artifact, observed live 2026-07-10, not a spelling reformat (canonical_key() already handles those). The tolerance is max(2us, 0.5% of the larger value): the 2us floor is 2x the observed ~1us quantization delta, and the 0.5% relative band covers tc’s %g display rounding at second scale (see the second-scale cases in TestTickQuantizationTolerance).

This cannot mask a real mismatch: an impairment that genuinely failed to apply (or applied as a different value) reads back as None (field missing), the PRIOR value (merge never took), or a different field entirely — never a same-field sub-percent delta on the value that WAS actually applied. None is only equivalent to None.

Pluggable link impairers: the LinkImpairer contract + IMPAIRERS registry.

Mirrors the transfer-backend registry (otto.host.transfer.registry): custom impairers register from init modules under a name; a host’s impairer pin / valid_impairers menu select one per placement host (spec §5). NetEm is the only first-party registrant (otto.link.netem).

otto.link.impairer.FIRST_SELECTOR_BAND = 4

prio bands 1-3 keep kernel-default priomap semantics; selectors start at band 4.

otto.link.impairer.MAX_SELECTORS = 8

Per-netdev selector cap (bands 4..11 inside the fixed 11-band prio root).

class otto.link.impairer.ScopedState(kind: ~typing.Literal['clean', 'whole', 'scoped', 'foreign'], whole: ~otto.link.params.ImpairmentParams | None = None, selectors: dict[~otto.link.params.Selector, tuple[int, ~otto.link.params.ImpairmentParams]] = <factory>)

Bases: object

Discriminated read-back of one placement netdev’s impairment shape.

Exactly one of four kinds (spec §1): clean (no otto state — including kernel-default root qdiscs), whole (today’s root netem, whole set), scoped (selectors maps each Selector to its (band, params)), or foreign (a root qdisc otto did not generate: reported by list, loudly refused on mutate).

kind : Literal['clean', 'whole', 'scoped', 'foreign']
whole : ImpairmentParams | None
selectors : dict[Selector, tuple[int, ImpairmentParams]]
classmethod clean() ScopedState

No otto impairment state on the netdev.

Today’s whole-link root netem.

classmethod from_selectors(selectors: dict[Selector, tuple[int, ImpairmentParams]]) ScopedState

Create a port-scoped tree: selector -> (band, params).

classmethod foreign() ScopedState

Report a root qdisc otto did not generate — never mutated, only reported.

class otto.link.impairer.LinkImpairer

Bases: object

Builds the shell commands that apply/read/clear one placement’s impairment.

Stateless: implementations build command strings and parse output; the orchestration layer (otto.link.manage) runs them on hosts.

host_families : ClassVar[frozenset[str]] = frozenset({})

Host families this impairer serves (e.g. frozenset({"unix"})).

supports_selectors : ClassVar[bool] = False

Whether this impairer implements the optional port-scoped surface (the scoped_* builders + parse_scoped()). Defaults off so third-party impairers are unaffected; a --port request routed to a non-supporting impairer is a loud capability error in orchestration.

apply_command(netdev: str, params: ImpairmentParams) str

Shell command applying params to netdev (idempotent replace).

read_command(netdev: str) str

Shell command whose output parse_read() understands.

clear_command(netdev: str) str

Shell command removing this impairer’s state from netdev.

parse_read(output: str) ImpairmentParams | None

Parse read_command() output; None = no impairment present.

scoped_root_command(netdev: str) str

Command creating the scoped classful root on netdev (idempotent).

scoped_band_command(netdev: str, band: int, params: ImpairmentParams) str

Command applying params as band band’s per-selector leaf (idempotent).

scoped_filter_commands(netdev: str, band: int, selector: Selector) list[str]

Commands steering selector’s traffic into band band.

scoped_clear_selector_commands(netdev: str, band: int, selector: Selector) list[str]

Commands removing selector’s filters and its band leaf (root kept).

scoped_read_commands(netdev: str) list[str]

Return the two read commands whose outputs parse_scoped() understands.

parse_scoped(qdisc_output: str, filter_output: str) ScopedState

Parse scoped_read_commands() outputs into a ScopedState.

otto.link.impairer.register_impairer(name: str, cls: type[LinkImpairer], *, overwrite: bool = False) None

Make a custom impairer available to lab data under name.

Call from an init module listed in .otto/settings.toml. The impairer must declare a non-empty LinkImpairer.host_families; otherwise it could never validate against any host and is rejected here.

otto.link.impairer.build_impairer(name: str) type[LinkImpairer]

Return the impairer class registered under name (rich unknown-name error).

NetEm — the first-party LinkImpairer (tc qdisc netem on unix hosts).

argv builders ALWAYS emit explicit units (spec §3.1) — tc’s bare-number semantics vary by parameter and iproute2 version. The parser reads tc qdisc show dev X back into ImpairmentParams (kernel qdisc config is the only state — spec §6) and tolerates both modern and old iproute2 formatting (50ms vs 50.0ms).

otto.link.netem.netem_args(params: ImpairmentParams) str

Render params as netem qdisc arguments with explicit units.

otto.link.netem.parse_qdisc_show(output: str) ImpairmentParams | None

Parse tc qdisc show dev X output; None = no root netem qdisc.

otto.link.netem.parse_scoped_outputs(qdisc_output: str, filter_output: str) ScopedState

Parse the two NetEmImpairer.scoped_read_commands() outputs.

Only trees otto generated parse as scoped; kernel-default roots (handle 0: / noqueue) are clean; a root netem is whole (the byte-identical v1 read-back); everything else is foreign. An otherwise-ours root with zero leaves and zero filters is clean — a timer race that empties the tree must not wedge exclusivity.

class otto.link.netem.NetEmImpairer

Bases: LinkImpairer

tc/netem on a unix host’s interface.

host_families : ClassVar[frozenset[str]] = frozenset({'unix'})

Host families this impairer serves (e.g. frozenset({"unix"})).

supports_selectors : ClassVar[bool] = True

Whether this impairer implements the optional port-scoped surface (the scoped_* builders + parse_scoped()). Defaults off so third-party impairers are unaffected; a --port request routed to a non-supporting impairer is a loud capability error in orchestration.

apply_command(netdev: str, params: ImpairmentParams) str

Idempotent tc qdisc replace command applying params to netdev.

read_command(netdev: str) str

tc qdisc show command; output is understood by parse_qdisc_show().

clear_command(netdev: str) str

tc qdisc del command removing the root netem qdisc from netdev.

parse_read(output: str) ImpairmentParams | None

Parse read_command() output via parse_qdisc_show().

scoped_root_command(netdev: str) str

Idempotent 11-band prio root; bands 1-3 keep kernel-default semantics.

scoped_band_command(netdev: str, band: int, params: ImpairmentParams) str

Idempotent netem leaf for band. classid/handle minors are HEX.

scoped_filter_commands(netdev: str, band: int, selector: Selector) list[str]

u32 filters steering selector into band band, fixed pref-slot order.

scoped_clear_selector_commands(netdev: str, band: int, selector: Selector) list[str]

Delete selector’s filters (by pref) then its band’s netem leaf.

scoped_read_commands(netdev: str) list[str]

Qdisc + filter reads for parse_scoped().

The filter read is guarded (2>/dev/null || true) belt-and-braces: captured live on the veggies bed, iproute2 6.1.0, 2026-07-11, tc filter show ... parent 1: on a netdev with no 1: parent (every clean or whole-link netdev) does NOT error — it exits 0 with empty stdout. The guard is kept anyway for older/other iproute2 builds where this call is documented to fail; either way the read path must treat empty/absent output as ‘no filters’, not a host error.

parse_scoped(qdisc_output: str, filter_output: str) ScopedState

Parse scoped_read_commands() outputs via parse_scoped_outputs().

Impairment placements: where one direction’s qdisc lands (spec §4/§9).

A placement is (host, netdev, direction). Two resolvers map a link to placements — endpoint mode and in-path (middlebox) mode — plus the two mandatory refusals: never a management interface, never a link with the local host as an endpoint.

class otto.link.placement.FlowDirection(value)

Bases: Enum

One direction of a link’s traffic, in endpoint order.

A_TO_B = 'a->b'
B_TO_A = 'b->a'
class otto.link.placement.Placement(host_id: str, netdev: str, direction: FlowDirection)

Bases: object

Where one direction’s impairment lands: a netdev on a host.

host_id : str
netdev : str
direction : FlowDirection
otto.link.placement.parse_ip_addr(output: str) dict[str, list[IPv4Interface]]

Parse ip -o addr show output into netdev -> addressed interfaces.

Refuse any link with the local host as an endpoint OR its middlebox (spec §9).

The local host’s connectivity to the bed IS otto’s management path, in EVERY mode — as an endpoint, or (link.impair) as the in-path middlebox that would service the impairment on otto’s own machine.

otto.link.placement.ensure_not_mgmt(placement: Placement, addr_table: dict[str, list[IPv4Interface]], mgmt_ip: str) None

Refuse a placement on the netdev carrying the host’s management ip (§9).

Only a POSITIVE match refuses: a mgmt ip invisible in the table (e.g. a NAT-fronted host) cannot be on the placement netdev.

otto.link.placement.ensure_not_hop_transit(placement: Placement, addr_table: dict[str, list[IPv4Interface]], dependent_mgmt_ips: Collection[tuple[str, str]]) None

Refuse a placement whose netdev carries another host’s hop/management transit (§9).

Where ensure_not_mgmt() protects the placement host’s OWN management interface, this protects a MIDDLEMAN’s forwarding interface. If some lab host reaches otto only by hopping THROUGH the placement host, degrading the netdev that carries that host’s management subnet severs otto → that host — a self-lockout one indirection out. dependent_mgmt_ips are the (host_id, mgmt_ip) pairs whose hop chain includes this placement’s host; a positive subnet match on the placement netdev refuses.

otto.link.placement.endpoint_placements(link: Link, directions: Collection[FlowDirection]) list[Placement]

Endpoint mode: each direction lands on its ORIGIN endpoint’s interface.

otto.link.placement.inpath_placements(link: Link, impair_host_id: str, addr_table: dict[str, list[IPv4Interface]], directions: Collection[FlowDirection]) list[Placement]

In-path mode: each direction lands on the middlebox netdev FACING it.

The facing netdev is the one toward the direction’s target endpoint, auto-resolved by subnet match (spec §4).

Impair/repair/list orchestration — kernel qdisc state is the ONLY state.

Reads go through host.exec (no privilege needed); mutations through host.run(cmd, sudo=host.current_user != "root"). Every host call is wrapped in asyncio.wait_for(..., _IMPAIR_HOST_TIMEOUT) and a down host is a loud, host-named RuntimeError — never a skip (spec §9, dev-VM rule).

These four functions — impair_link(), repair_link(), repair_all(), read_link_states() — plus find_link() ARE the public API (spec’s single-API constraint): the CLI, the future GUI topology overlay, and any direct importer call exactly these. Nothing here prints or knows about exit codes/colors.

class otto.link.manage.AppliedPlacement(placement: Placement, params: ImpairmentParams, selector: Selector | None = None)

Bases: object

One placement’s post-verify impairment state.

placement : Placement

Where this impairment landed.

params : ImpairmentParams

The merged params actually verified present after the mutation.

selector : Selector | None

Set when this was a port-scoped application (--port).

class otto.link.manage.ImpairReport(link_id: str, applied: list[~otto.link.manage.AppliedPlacement] = <factory>)

Bases: object

Outcome of impair_link(): every placement actually mutated.

applied : list[AppliedPlacement]
class otto.link.manage.RepairReport(link_id: str, cleared: list[~otto.link.placement.Placement] = <factory>, timers_cancelled: int = 0)

Bases: object

Outcome of repair_link(): what got cleared, and how many timers died.

cleared : list[Placement]
timers_cancelled : int
class otto.link.manage.DirectionState(whole: ~otto.link.params.ImpairmentParams | None = None, scoped: dict[~otto.link.params.Selector, ~otto.link.params.ImpairmentParams] = <factory>, foreign: bool = False)

Bases: object

One direction’s full impairment shape (the list/GUI read feed).

At most one of whole / scoped / foreign is populated (whole-link and port-scoped are exclusive per netdev in v1; a foreign tree is opaque). All three empty = clean.

whole : ImpairmentParams | None
scoped : dict[Selector, ImpairmentParams]
foreign : bool
class otto.link.manage.LinkState(link: ~otto.link.model.Link, by_direction: dict[~otto.link.placement.FlowDirection, DirectionState | None] = <factory>, impairable: bool = True, unreachable: bool = False)

Bases: object

One link’s current impairment state, direction by direction (for list).

by_direction : dict[FlowDirection, DirectionState | None]

Per-direction shape; None = that direction’s host couldn’t be read.

impairable : bool

False when the link structurally can’t be impaired (refusal/unnamed/etc).

unreachable : bool

True when at least one placement host couldn’t be reached to read state.

Resolve ident (a link id or its name) against lab.static_links().

Raises a ValueError listing every known id when ident matches neither, so a typo’d CLI argument gets a usable hint.

Impair link ident with params (merge-read-modify-replace, verified).

params merges over each placement’s CURRENTLY-applied state (merged_over()) — a bare re-impair layers onto what’s already there, an explicit zero clears just that one param. Every placement’s existing expire-timer is cancelled before the mutation runs; a fresh one is launched after a successful verify when expire is given.

--from (from_host) narrows endpoint mode to the direction originating at that host; omitted, both directions are impaired. In-path links (link.impair set) ignore endpoint selection and always place on the middlebox’s facing interfaces.

selector (--port) routes the mutation through the port-scoped path instead: params merges over just THAT selector’s currently-applied state (not the whole netdev’s), landing in its own prio band (assigned on first use, kept across re-impairs, capped at MAX_SELECTORS per netdev) with its own pair of u32 filters. Whole-link and port-scoped impairment are exclusive per netdev (spec §1): a bare impair against scoped state, or a scoped impair against whole-link state, is a loud ValueError telling the operator to repair first. A host whose impairer doesn’t declare supports_selectors is also a loud capability error — never a silent fallback to whole-link. Expire-timers follow the same split: a bare impair only ever cancels/launches v1 whole-link timers; a scoped impair only ever cancels/launches its OWN selector’s v2 timer, leaving every other selector’s timer (and any v1 timer, which scoped state can’t have anyway) untouched.

No half-impairments: if any placement fails mid-way (mutation doesn’t verify, host unreachable, etc.), every placement touched in this call — INCLUDING the one whose own mutation just failed — is restored to its PRIOR state before the error propagates.

Clear link ident’s impairment state and cancel its timers.

Bare (selector=None): clears EVERYTHING per placement that has any otto state — whole-link or the entire scoped tree, each a single root delete — and cancels every v1 and v2 timer. With selector: clears just that selector (deleting the root when it is the last one) and cancels only its own v2 timer; a selector that isn’t present clears nothing.

Every clear is verified by a post-clear re-read: a clear that silently didn’t take is a loud, host-named failure, never reported as cleared.

async otto.link.manage.repair_all(lab: Lab) tuple[list[RepairReport], list[str]]

Repair every static link in lab; never raises.

A link that structurally can’t be impaired (ValueError — no named interface, local-link, mgmt refusal, …) is silently skipped: it was never impaired in the first place. A link whose repair fails for a live reason (RuntimeError — host down, command failed) is collected into failures instead of aborting the rest.

Read the current impairment state of every static link.

This is the list/GUI-overlay feed.

Reads only (exec, no sudo); never raises per-link, so one bad host can’t hide the rest of the fleet’s state from a caller like otto link list or a topology overlay.

otto-impair argv sentinel + expire-timer discovery (spec §7; v2 spec 2026-07-11 §1).

Wire formats, percent-encoded segments, framing via otto.host.daemon:

  • v1 (whole-link timers): otto-impair:v1:<link-id>:<netdev>

  • v2 (per-selector timers): otto-impair:v2:<link-id>:<netdev>:<port>:<proto-or-empty>

v1 stays parseable forever so repair cancels timers launched by older otto. The timer process’s argv IS the state — discoverable via ps, unambiguously otto’s, owner-agnostic.

otto.link.sentinel.IMPAIR_PS_COMMAND : str = "ps -eo pid= -eo etime= -eo args= 2>/dev/null | \\grep -a ' otto-impair:' || true"

The per-host expire-timer scan. Built by otto.host.daemon.ps_scan_command() — see it for the procps portability story; bytes pinned by TestWireGolden.

class otto.link.sentinel.ImpairTimer(pid: int, link_id: str, netdev: str, selector: Selector | None)

Bases: object

One live expire-timer seen in a ps scan.

pid : int
netdev : str
selector : Selector | None

None = a v1 whole-link timer; set = a v2 per-selector timer.

otto.link.sentinel.encode_impair_sentinel(link_id: str, netdev: str) str

v1 sentinel token tagging one placement’s WHOLE-LINK expire timer.

Whole-link timers deliberately stay on v1 — the whole-link path is byte-identical to pre-selector otto (spec 2026-07-11 hard constraint).

otto.link.sentinel.encode_impair_sentinel_v2(link_id: str, netdev: str, selector: Selector) str

v2 sentinel token tagging one selector’s expire timer on one placement.

otto.link.sentinel.parse_impair_sentinel(token: str) tuple[str, str, Selector | None] | None

Decode a v1 OR v2 token to (link_id, netdev, selector); None if not ours.

v1 tokens decode with selector=None. Unknown versions and malformed v2 payloads (non-numeric/out-of-range port, unknown proto) parse to None, never an error — the framing stability contract.

otto.link.sentinel.parse_impair_ps(output: str) list[ImpairTimer]

Reconstruct live timers from IMPAIR_PS_COMMAND output (v1 AND v2).

Structural validation only: endpoint references (host ids, interface keys) are resolved against the loaded host set at lab-load time (otto.link.derive.resolve_declared_links()), where the hosts are known.

class otto.models.link.LinkEndpointSpec(*, host: str, interface: str | None = None)

Bases: OttoModel

One end of a declared link: a host id plus (optionally) a named interface.

interface (a key in the host’s interfaces map, i.e. a netdev name) is required only when the host defines more than one interface; with one or none, otto assumes the sole interface / the management ip.

host : str
interface : str | None
class otto.models.link.LinkSpec(*, endpoints: Annotated[list[LinkEndpointSpec], MinLen(min_length=2), MaxLen(max_length=2)], protocol: str = 'tcp', name: str | None = None, impair: str | None = None, management: str | None = None)

Bases: OttoModel

Boundary spec for one links entry in lab.json.

protocol is informational for declared links (what the route carries); it becomes functional for dynamic links (sub-project #2). management is reserved for sub-project #5: accepted and carried, not yet consumed.

endpoints : list[LinkEndpointSpec]
protocol : str
name : str | None
impair : str | None

In-path middlebox host id servicing this link’s impairment (sub-project #3, spec §10). Reference-validated at lab load (otto.link.derive.resolve_declared_links()). management remains reserved for #5.

management : str | None