diff --git a/.claude/skills/add-a-domain/SKILL.md b/.claude/skills/add-a-domain/SKILL.md index 3e48571..980082b 100644 --- a/.claude/skills/add-a-domain/SKILL.md +++ b/.claude/skills/add-a-domain/SKILL.md @@ -1,6 +1,6 @@ --- name: add-a-domain -description: Add or extend an osapi API domain — a provider plus every layer it must appear in. Covers the provider implementation and its platform variants, agent processor and registry wiring, the OpenAPI spec and validation tags, the Echo handler with broadcast targeting, handler registration and startup wiring, the SDK service, CLI commands, and the docs and permission tables a new domain must be added to. Use when asked to add a domain, add a provider, add an operation or endpoint to an existing domain, wire a provider into the agent, add an SDK service, add CLI commands for a domain, or when asked what a new domain has to touch, why a domain feels half-finished, or which layer is missing. Also use when reviewing a domain for cross-layer consistency against an existing one. +description: Add or extend an osapi API domain, a provider plus every layer it must appear in. Covers the provider implementation and its platform variants, agent processor and registry wiring, the OpenAPI spec and validation tags, the Echo handler with broadcast targeting, handler registration and startup wiring, the SDK service, CLI commands, and the docs and permission tables a new domain must be added to. Use when asked to add a domain, add a provider, add an operation or endpoint to an existing domain, wire a provider into the agent, add an SDK service, add CLI commands for a domain, or when asked what a new domain has to touch, why a domain feels half-finished, or which layer is missing. Also use when reviewing a domain for cross-layer consistency against an existing one. compatibility: Requires an osapi checkout with mise and just available. Commands run through `mise exec -- just`. license: MIT metadata: @@ -46,14 +46,14 @@ Never copy a domain's files wholesale. Read one, then write the new one. The layers depend on each other in one direction. Going out of order means regenerating or rewriting. -1. **Provider** — the operations, with its own tests passing. -2. **Agent** — processor and registry, so a job reaches the provider. -3. **OpenAPI spec** — then `just generate`, which produces the server, the +1. **Provider**, the operations, with its own tests passing. +2. **Agent**, processor and registry, so a job reaches the provider. +3. **OpenAPI spec**, then `just generate`, which produces the server, the combined spec, and the SDK's generated client together. -4. **Handler** — with validation and broadcast, then registration and startup. -5. **SDK service** — wrapping the generated client. -6. **CLI** — wrapping the SDK. -7. **Docs and tables** — feature page, CLI pages, permissions, navbars. +4. **Handler**, with validation and broadcast, then registration and startup. +5. **SDK service**, wrapping the generated client. +6. **CLI**, wrapping the SDK. +7. **Docs and tables**, feature page, CLI pages, permissions, navbars. A new permission is decided at step 3 and lands in step 7. Write it down when you choose it; it is the thing most often missed. diff --git a/.claude/skills/add-a-domain/references/agent.md b/.claude/skills/add-a-domain/references/agent.md index 8d075b1..dcba387 100644 --- a/.claude/skills/add-a-domain/references/agent.md +++ b/.claude/skills/add-a-domain/references/agent.md @@ -7,7 +7,7 @@ dispatch and facts wiring. | What you need to know | Where | | --- | --- | -| Two files connect a provider, and what does **not** change — `agent/types.go`, `agent/agent.go`, the `JobClient` interface | [FR-009](../../../../components/osapi/domains.md) | +| Two files connect a provider, and what does **not** change: `agent/types.go`, `agent/agent.go`, the `JobClient` interface | [FR-009](../../../../components/osapi/domains.md) | | The `FactsAware` obligation: embed it, add the compile-time `FactsSetter` check | [FR-010](../../../../components/osapi/domains.md) | This file holds the shapes and the file names. The rules above are stated once, @@ -104,7 +104,7 @@ in full. What matters when adding an operation, and where each rule is stated: | --- | --- | | Delivery is at-least-once, and the agent checks for a recorded response before executing | [FR-009](../../../../components/osapi/job-system.md) | | Which operations make that check load-bearing rather than theoretical | [FR-010](../../../../components/osapi/job-system.md) | -| A job that has run is terminal — a failure is reported, not retried by redelivery | [FR-011](../../../../components/osapi/job-system.md) | +| A job that has run is terminal, a failure is reported, not retried by redelivery | [FR-011](../../../../components/osapi/job-system.md) | | What happens when the response cannot be written after the work ran | [FR-012](../../../../components/osapi/job-system.md) | | Which failures terminate a message instead of redelivering it | [FR-013](../../../../components/osapi/job-system.md) | | The consumer's delivery settings, as defaults a deployment may override | [FR-014](../../../../components/osapi/job-system.md) | @@ -113,14 +113,14 @@ in full. What matters when adding an operation, and where each rule is stated: Two consequences for a new operation, which are yours rather than the system's: - **Redelivery is not your safety net.** The provider's idempotency is what makes a - repeat safe — the contract's own requirement, not this one. + repeat safe, the contract's own requirement, not this one. - **`job retry` creates a new job** rather than replaying the old message, so nothing per-domain handles it. ## Facts `provider.WireProviderFacts(a.GetFacts, registry.AllProviders()...)` injects -facts into every registered provider — one call, in `internal/agent/agent.go`. A +facts into every registered provider: one call, in `internal/agent/agent.go`. A provider registered through the registry is covered; one constructed and passed somewhere else is not. The obligation on the provider struct itself is [FR-010](../../../../components/osapi/domains.md), and what a provider does with facts is diff --git a/.claude/skills/add-a-domain/references/api.md b/.claude/skills/add-a-domain/references/api.md index ac9ae14..b425860 100644 --- a/.claude/skills/add-a-domain/references/api.md +++ b/.claude/skills/add-a-domain/references/api.md @@ -15,7 +15,7 @@ does not: where the files go, what they are called, and the scaffolding to start from. The split is deliberate. A rule restated here would drift from the one in the -corpus, and the copy an agent happened to load would win — which is the failure +corpus, and the copy an agent happened to load would win, which is the failure [003-corpus-backfill](../../../../CONSTITUTION.md) exists to end. @@ -25,7 +25,7 @@ exists to end. | Path parameters are the trap, and what actually validates one | FR-012 | | Separate verbs for create and update, and why a combined upsert is forbidden | FR-013 | | The six API design guidelines, including path versus query parameters | FR-014 | -| What `{hostname}` accepts — a literal, `_any`, `_all`, a label selector | FR-015 | +| What `{hostname}` accepts, a literal, `_any`, `_all`, a label selector | FR-015 | | Broadcast is mandatory, and both paths return the same collection shape | FR-016 | | The job client has four methods, and an operation adds none | FR-017 | | `Handler()` returns route closures, and the `Server` struct does not change | FR-018 | @@ -90,7 +90,7 @@ handlers = append(handlers, Stated here because they are real and nothing else states them. Both belong in the corpus and neither is there, which is recorded rather than left to be -discovered — the same treatment FR-019 gives the absent `sdk-standards` +discovered, the same treatment FR-019 gives the absent `sdk-standards` capability. **A custom validation rule is a registered validator.** It belongs in @@ -106,7 +106,7 @@ corpus does not say is that a domain must not write its own target parser. that differ in how much damage they can do want two permissions however similar their shape. A new one must be added to the built-in role expansion, the permission constants, the SDK, and the roles tables in -`features/authentication.md` and `usage/configuration.md` — see +`features/authentication.md` and `usage/configuration.md`, see [docs.md](docs.md). A permission that exists in the spec but in no role reaches nobody. @@ -115,9 +115,9 @@ nobody. Testing conventions are osapi's `CONTRIBUTING.md`, under "Testing". What this layer adds to a public suite: -- `TestXxxHTTP` — raw HTTP through the full Echo middleware stack: valid input +- `TestXxxHTTP`, raw HTTP through the full Echo middleware stack: valid input succeeds, invalid input returns 400 with the message. -- `TestXxxRBACHTTP` — no token is 401, a token without the permission is 403, a +- `TestXxxRBACHTTP`, no token is 401, a token without the permission is 403, a token with it succeeds. Cover validation failure, success, a provider error surfaced from the job, and diff --git a/.claude/skills/add-a-domain/references/cli.md b/.claude/skills/add-a-domain/references/cli.md index ff62380..2ee3d99 100644 --- a/.claude/skills/add-a-domain/references/cli.md +++ b/.claude/skills/add-a-domain/references/cli.md @@ -14,7 +14,7 @@ The obligations this layer carries are stated in | `--json` on every command, and flags rather than positional arguments for IDs | FR-021 | | `cli.PrintKV` for a single resource, `cli.PrintCompactTable` for rows | FR-021 | | Every response code the spec declares handled in the status switch | FR-021 | -| What `--target` accepts — a literal, `_any`, `_all`, a label selector | FR-015 | +| What `--target` accepts, a literal, `_any`, `_all`, a label selector | FR-015 | | What verifies a finished domain, and what Step 8 alone misses | FR-024 | ## Files @@ -49,7 +49,7 @@ operators read these side by side. ## Three rules the corpus does not yet hold -Stated here because they are real and nothing else states them — not the corpus, +Stated here because they are real and nothing else states them, not the corpus, not osapi's `CONTRIBUTING.md`. Recorded as unstated rather than left to be discovered, the same treatment FR-019 gives the absent `sdk-standards` capability. diff --git a/.claude/skills/add-a-domain/references/docs.md b/.claude/skills/add-a-domain/references/docs.md index c8237b9..fd2634e 100644 --- a/.claude/skills/add-a-domain/references/docs.md +++ b/.claude/skills/add-a-domain/references/docs.md @@ -1,7 +1,7 @@ # Docs and tables The Docusaurus site is user-facing: what a feature does, how to call it, what the -SDK exposes. Development guidance is not there — it is in the corpus, and this +SDK exposes. Development guidance is not there: it is in the corpus, and this skill cites it. A domain that works but appears in none of these is invisible to everyone who did diff --git a/.claude/skills/add-a-domain/references/sdk.md b/.claude/skills/add-a-domain/references/sdk.md index 531e699..b2ba49d 100644 --- a/.claude/skills/add-a-domain/references/sdk.md +++ b/.claude/skills/add-a-domain/references/sdk.md @@ -16,7 +16,7 @@ binding rules were "the `sdk-standards` capability in this repository", that the bound `osapi-orchestrator` too, and that the capability won any disagreement. Nothing of the sort has been written. The claim was also on osapi's `adding-an-api-domain.md`, so two documents deferred to a specification that reads -as settled and does not exist — recorded as +as settled and does not exist, recorded as [FR-019](../../../../components/osapi/domains.md)'s gap rather than repeated here. What that means in practice: the conventions below and in FR-019 and FR-020 are diff --git a/.claude/skills/document/README.md b/.claude/skills/document/README.md index 690175a..faa01a1 100644 --- a/.claude/skills/document/README.md +++ b/.claude/skills/document/README.md @@ -36,7 +36,7 @@ The first is a page under `components//`, the second belongs in `ARCHITECTURE.md`, and a rule every repository follows belongs in `CONSTITUTION.md`. -[references/voice.md](references/voice.md) carries the writing standard: plain +[VOICE.md](../../../VOICE.md) carries the writing standard: plain engineering prose, concrete over abstract, say the tradeoff, and the tells to avoid. diff --git a/.claude/skills/document/SKILL.md b/.claude/skills/document/SKILL.md index d6a6e86..c5e7c66 100644 --- a/.claude/skills/document/SKILL.md +++ b/.claude/skills/document/SKILL.md @@ -76,8 +76,7 @@ alone. ## 4. Write it The voice: an engineer explaining a system to another engineer who has to work on -it. [references/voice.md](references/voice.md) has the specifics and the tells to -avoid. +it. [VOICE.md](../../../VOICE.md) has the specifics and the tells to avoid. What a page does, in order of what a reader needs: diff --git a/.claude/skills/org-status/references/issues.md b/.claude/skills/org-status/references/issues.md index 9888671..7323758 100644 --- a/.claude/skills/org-status/references/issues.md +++ b/.claude/skills/org-status/references/issues.md @@ -46,7 +46,7 @@ gh pr list --repo "osapi-io/$r" --state open \ An issue named there is in flight, and belongs in the pull request block rather than in a block of its own. An issue whose fix has already merged is still open -only because nobody closed it — say so, because it reads as outstanding work. +only because nobody closed it: say so, because it reads as outstanding work. ## Trackers diff --git a/.claude/skills/org-status/references/merging.md b/.claude/skills/org-status/references/merging.md index 3797ddf..b7be061 100644 --- a/.claude/skills/org-status/references/merging.md +++ b/.claude/skills/org-status/references/merging.md @@ -46,7 +46,7 @@ repository often are not. Two bumps that touch the same file cannot both be merged from the state they were built in. The first merge moves the default branch and the second is now -based on something that no longer exists, so it conflicts — or worse, merges +based on something that no longer exists, so it conflicts, or worse, merges cleanly and drops the first one's edit. `mergeable` says `CLEAN` for both right up until the first one lands, which is what makes this easy to get wrong. @@ -56,9 +56,9 @@ So decide by the files, not by the colour of the tick: gh pr view --repo "osapi-io/$r" --json files -q '.files[].path' ``` -- **No overlap** — merge them together. Four bumps each touching a different +- **No overlap**, merge them together. Four bumps each touching a different `examples//go.mod` do not interact. -- **Overlap** — serialize, one merge at a time: +- **Overlap**, serialize, one merge at a time: 1. merge the first 2. `@dependabot rebase` the next, and wait for its checks 3. merge it, and repeat diff --git a/.claude/skills/org-status/references/pull-requests.md b/.claude/skills/org-status/references/pull-requests.md index e46112b..6ded085 100644 --- a/.claude/skills/org-status/references/pull-requests.md +++ b/.claude/skills/org-status/references/pull-requests.md @@ -35,14 +35,14 @@ gh pr list --repo "osapi-io/$r" --state open \ ## Fields worth reporting -- `isDraft` — a draft is not waiting on review. Say so rather than counting it +- `isDraft`, a draft is not waiting on review. Say so rather than counting it as pending. -- `mergeable` — `CONFLICTING` means it needs a rebase before anything else. +- `mergeable`: `CONFLICTING` means it needs a rebase before anything else. `UNKNOWN` means GitHub is still computing it, so re-query rather than reporting it as a problem. -- `reviewDecision` — empty string means no review has been requested or given. +- `reviewDecision`, empty string means no review has been requested or given. `APPROVED` means it is ready to merge. -- `createdAt` — sort oldest first. Age is the signal. +- `createdAt`, sort oldest first. Age is the signal. ## Checks on a PR diff --git a/.claude/skills/org-status/references/quality.md b/.claude/skills/org-status/references/quality.md index 46485dd..34d922c 100644 --- a/.claude/skills/org-status/references/quality.md +++ b/.claude/skills/org-status/references/quality.md @@ -103,7 +103,7 @@ go directive: the fix is the same command in each, and a new linter release puts all of them behind at once. A stale pin is not cosmetic. A linter predating the toolchain crashes rather -than reporting findings — v2.12.2 died inside `buildir` under Go 1.27 — and the +than reporting findings, v2.12.2 died inside `buildir` under Go 1.27, and the crash arrives as a red build on an unrelated pull request. This drift was invisible for as long as it existed, because the shared `deps` diff --git a/README.md b/README.md index e8eb2fa..faa480b 100644 --- a/README.md +++ b/README.md @@ -5,96 +5,53 @@ # specs -The design docs for [osapi-io]. How the six repositories are built and why, in -one place, kept current as they change. No product code here. +The design docs for [osapi-io]. How each repository is built and why, written +before the code and corrected when the code proves them wrong. ## Usage -[osapi-io] makes a Linux host behave like an appliance. One binary and a config -file give you a REST API, a CLI, a Go SDK and an embedded dashboard over -hostname, DNS, disk, memory, load, packages, services, users, sysctl, cron, -certificates, containers, files and command execution, across a fleet rather -than one box. - -The design fact everything else follows from: **work reaches a host by being -queued, not by being called.** The controller writes a job and waits; an agent -picks it up and a provider does the work on the machine. At-least-once delivery, -the idempotency providers owe, two independent timeouts and a per-host result -all come out of that one choice. +This repository holds no product code. It holds the standing description of how +every other repository behaves, which is what the next change reads first. ``` components/ one page per repository, plus a page per subject -ARCHITECTURE.md how the six fit together, and what breaks what +ARCHITECTURE.md how they fit together, and what breaks what CONSTITUTION.md the rules every repository follows history/ superseded specs, kept for the record ``` -Read [osapi](components/osapi/README.md) first. Twelve subject pages hang off -it, and four of the other five repositories either feed it or consume it. - -## Doc-driven development - -Design something by writing its page. Build it. Correct the page where building -proved it wrong. Same page all three times, and nothing is converted from one -form into another, because that conversion is where the design and the docs -drift apart. - -So a change here is one of four things: - -| You are | Change | -| --------------------------------------- | ------------------------------- | -| Designing something new | A new page under its component | -| Changing how something behaves | The page that already covers it | -| Agreeing something between repositories | `ARCHITECTURE.md` | -| Binding every repository to a rule | `CONSTITUTION.md` | - -[CONTRIBUTING.md](CONTRIBUTING.md) has the test for which, and what a page looks -like. +Doc-driven: you design something by writing its page, build it, then correct the +page where building proved it wrong. Same page all three times, and nothing is +converted from one form into another, because that conversion is where the +design and the docs drift apart. -### What keeps it honest +Two things make that worth the overhead. A reviewer reads the design on its own, +separate from the diff that implements it. And one place describes a change +spanning several repositories, instead of scattering it across them. -`just test` runs two scripts and fails the build on either. -[check-counts](scripts/check-counts.py) takes every count in every page and runs -the command written beside it, in the repository that page describes, so a -number that moved breaks CI rather than sitting there wrong. +`just test` keeps it honest. [check-counts](scripts/check-counts.py) runs every +count in every page against the command written beside it, in the repository +that page describes, so a number that moved breaks the build. [check-docs](scripts/check-docs.py) fails on a dead link, a page missing from its index, or a page that reads like a specification instead of documentation. +Neither catches prose that drifted from the code: a reader does, and has found +more than both scripts together. -Neither catches prose that drifted from the code. A reader does: hand somebody -the page and nothing else, ask them the question it claims to answer, and fix -what they could not work out. That has found more than both scripts together, -including seven permissions where a page said one, thirteen struct fields where -it said fourteen, and a bucket TTL described backwards. - -## The design docs - -| Repository | Is | -| ------------------------------------------------------------- | -------------------------------------------- | -| [osapi](components/osapi/README.md) | The API and the agent that manage a host | -| [osapi-orchestrator](components/osapi-orchestrator/README.md) | A declarative layer over osapi's SDK | -| [nats-client](components/nats-client/README.md) | A wrapper over the NATS client | -| [nats-server](components/nats-server/README.md) | A NATS server embedded in its consumer | -| [gohai](components/gohai/README.md) | A system fact collection library, standalone | -| [osapi-justfiles](components/osapi-justfiles/README.md) | Shared `just` recipes | - -The four things most likely to catch you out, all written up: a -[missing row in a broadcast result](components/osapi/agent-identity.md) is not -an error and nothing reports it, [audit redaction](components/osapi/audit.md) is -a name-matched denylist with no test behind it, a -[direct permission](components/osapi/permissions.md) silently nullifies every -role on the token, and [ten minutes](components/osapi/exec.md) is a ceiling -rather than a fallback, so a job that needs twenty does not get them. +[CONTRIBUTING.md](CONTRIBUTING.md) has the workflow, the test for where a change +belongs, and what a page looks like. [VOICE.md](VOICE.md) has how they are +written. ## Skills -Skills here answer questions that span every repository, and carry the -operational knowledge for working in them. +Skills in this repository answer questions that span every repository in the +organization, and carry the operational knowledge for working in them. None of them lists what it describes. `org-status` takes the repository list from GitHub on each run, `add-a-domain` resolves its reference domain from the -codebase, and `document` reads the component pages that exist rather than a -table of them. An inventory written into a skill is right the day it is written -and wrong after the next change, with nothing marking the moment. +codebase, and `document` reads the pages that exist rather than a table of them, +so all three stay correct as repositories and layers come and go. An inventory +written into a skill is right the day it is written and wrong after the next +change, with nothing marking the moment. | Skill | Answers | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | @@ -105,16 +62,59 @@ and wrong after the next change, with nothing marking the moment. Each follows the [Agent Skills] format: a slim `SKILL.md` that routes, with the detail in reference files an agent reads only when the question calls for them. -## history/ +## The architecture documentation + +[osapi-io] makes a Linux host behave like an appliance: one binary and a config +file give you a REST API and a CLI for reading and changing system +configuration, over a fleet rather than one box. + +The design fact everything else follows from is that **work reaches a host by +being queued, not by being called.** The controller writes a job and waits; an +agent picks it up and a provider does the work on the machine. At-least-once +delivery, the idempotency providers owe, two independent timeouts and a per-host +result all come out of that one choice. + +Each repository's page is its architecture document, kept current as changes +land. Start with [how they fit together](ARCHITECTURE.md), or go straight to +one. + +| Repository | Is | +| ------------------------------------------------------------- | ------------------------------------------------------------------ | +| [osapi](components/osapi/README.md) | The API and the agent that manage a host | +| [osapi-orchestrator](components/osapi-orchestrator/README.md) | A declarative layer over osapi's SDK | +| [nats-client](components/nats-client/README.md) | A wrapper over the NATS client | +| [nats-server](components/nats-server/README.md) | A NATS server embedded in its consumer | +| [gohai](components/gohai/README.md) | A system fact collection library, standalone | +| [osapi-justfiles](components/osapi-justfiles/README.md) | Shared `just` recipes, fetched by every repository with a justfile | + +osapi's is the one to read first, since most of the others either feed it or +consume it. gohai is the exception and reads standalone. osapi links out to +subjects of its own: the message bus, the job system, providers, building a +domain, agent identity, permissions, the audit trail, running commands, the Go +SDK, the embedded UI, configuration and observability. + +The things most likely to catch you out, each written up where it belongs: a +[missing row in a broadcast result](components/osapi/agent-identity.md) is not +an error and nothing reports it, [audit redaction](components/osapi/audit.md) is +a name-matched denylist with no test behind it, a +[direct permission](components/osapi/permissions.md) silently nullifies every +role on the token, and [ten minutes](components/osapi/exec.md) is a ceiling +rather than a fallback. + +The specs under [history/](history/) are the process that produced these +documents. They record what a change was going to do, are not maintained, and +where one disagrees with a page the page is right. + +## Documentation -Fifteen specifications written under a workflow this repository no longer uses. -Kept because they record what was decided and when, not maintained, and where -one disagrees with a page under `components/` the page is right. +[CONTRIBUTING.md](CONTRIBUTING.md) covers prerequisites, setup, where a change +belongs, and the PR workflow. [CONSTITUTION.md](CONSTITUTION.md) is the rules. +[VOICE.md](VOICE.md) is how the docs are written. ## Contributing -See the [Contributing](CONTRIBUTING.md) guide for prerequisites, the test for -where a change belongs, what a page looks like, and the PR workflow. +See the [Contributing](CONTRIBUTING.md) guide for prerequisites, setup, +conventions, and the PR workflow. ## License diff --git a/.claude/skills/document/references/voice.md b/VOICE.md similarity index 85% rename from .claude/skills/document/references/voice.md rename to VOICE.md index c3152b7..7c38d6c 100644 --- a/.claude/skills/document/references/voice.md +++ b/VOICE.md @@ -1,9 +1,13 @@ # The voice +How every document in this repository is written, and every skill that writes +one. Cited rather than restated: if you are about to paraphrase this into a +skill or a page, link here instead. + An engineer explaining a system to another engineer who has to work on it. -The test: **would a senior engineer write this in an internal design doc?** If it -reads like a paper, rewrite it. If it reads like documentation written for +The test: **would a senior engineer write this in an internal design doc?** If +it reads like a paper, rewrite it. If it reads like documentation written for beginners, rewrite it. If it reads like something trying to sound technical, rewrite it. @@ -36,11 +40,12 @@ controller has a configurable timeout". **Say the tradeoff.** Where there was a real choice, say what it cost. "Two buckets rather than one means a reader of a result does not walk the status -history; it also means the result's TTL is a separate setting nobody remembers to -set." A decision with no cost stated reads as if there was nothing to decide. +history; it also means the result's TTL is a separate setting nobody remembers +to set." A decision with no cost stated reads as if there was nothing to decide. -**Keep the real detail.** Simplifying until the tradeoffs disappear is worse than -being dense. The reader is experienced; they are not familiar with this system. +**Keep the real detail.** Simplifying until the tradeoffs disappear is worse +than being dense. The reader is experienced; they are not familiar with this +system. **Lead with what will bite them.** A rule with a silent failure mode is worth more than three paragraphs about structure. @@ -86,9 +91,9 @@ Weak: Better: -> Work reaches a host by being queued, not by being called. The controller writes -> a job, announces it, and waits; an agent picks it up and writes a response -> back. Everything awkward about the system comes out of that split: +> Work reaches a host by being queued, not by being called. The controller +> writes a job, announces it, and waits; an agent picks it up and writes a +> response back. Everything awkward about the system comes out of that split: > at-least-once delivery, the idempotency providers owe, two independent > timeouts, and a per-host result instead of one answer.