From e6d51b6685c5cd74fdae6469e5dce449bf1d07cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 1 Oct 2026 21:26:25 -0700 Subject: [PATCH] docs: how a provider's error reads A provider's error reaches a job result, the audit log and a CLI user's screen, so its wording is an interface. Nothing said what shape it takes and the tree grew four. Settles it as : what went wrong, which sysctl and netplan already use and which sorts and greps usefully. Names the two habits already in the tree and why each is wrong. An error that announces it failed says nothing the wrapping did not, because these are almost always wrapped. An error naming the command rather than the operation leaks the implementation into a message a user reads, and stops being true when the implementation changes. The four sentinels stay exactly as they are. They are compared with errors.Is rather than read, so the convention does not apply to them. Refs osapi-io/osapi#565 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c --- .../add-a-domain/references/provider.md | 1 + components/osapi/providers.md | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/.claude/skills/add-a-domain/references/provider.md b/.claude/skills/add-a-domain/references/provider.md index 9ea4745..9f338d2 100644 --- a/.claude/skills/add-a-domain/references/provider.md +++ b/.claude/skills/add-a-domain/references/provider.md @@ -31,6 +31,7 @@ corpus, and the copy an agent happened to load would win. | Secrets reach a command without appearing in it | [secrets are not arguments](../../../../components/osapi/providers.md#a-secret-never-appears-in-a-commands-arguments) | | A caller's value never becomes an option | [never parsed as an option](../../../../components/osapi/providers.md#a-callers-value-is-never-parsed-as-an-option) | | Filesystem access, the exec manager, and why a file is not written in place | [what it goes through](../../../../components/osapi/providers.md#what-a-provider-goes-through-not-around) | +| How an error reads, and the two habits to avoid | [how an error reads](../../../../components/osapi/providers.md#how-an-error-reads) | | What a provider does not touch | [what it does not touch](../../../../components/osapi/providers.md#what-a-provider-does-not-touch) | | The testing obligations that belong to the provider | [what its tests owe](../../../../components/osapi/providers.md#what-its-tests-owe) | diff --git a/components/osapi/providers.md b/components/osapi/providers.md index 5ab5398..179a5c1 100644 --- a/components/osapi/providers.md +++ b/components/osapi/providers.md @@ -163,6 +163,47 @@ directly. **A file is not written in place.** A partially written configuration file is worse than no write at all, so the deployer writes and moves. +## How an error reads + +A provider's error reaches a job result, the audit log and a CLI user's screen, +so its wording is an interface rather than a debugging aid. + +The shape is **` : what went wrong`**. + +``` +sysctl create: key must not be empty +schedule delete: not managed by osapi +file deploy: execute template: no such template +``` + +That ordering sorts and greps usefully, and it reads the same whether the error +arrives alone or wrapped by three callers above it. + +Two habits to avoid, because both are already in the tree. + +**An error does not announce that it failed.** `failed to execute template` is +almost always wrapped, so the reader sees "deploy schedule entry: failed to +execute template: ...", where the words carry nothing the context did not +already. Name what was being attempted and let the wrapping supply the rest. + +**An error names the operation, not the command.** `chpasswd failed` leaks the +implementation into a message a CLI user reads, and it stops being true the day +the implementation changes. `user set password` survives that. + +The four sentinels are the exception and stay exactly as they are, because they +are compared with `errors.Is` rather than read: + +```go +ErrUnsupported = errors.New("operation not supported on this OS family") +ErrNotFound = errors.New("not found") +ErrNotManaged = errors.New("not managed by osapi") +ErrNotInstalled = errors.New("not installed") +``` + +Wrapping with `%w` on any path that carries a cause is not optional. An error +that loses its cause cannot be matched by a caller, which is what the sentinels +exist for. + ## What a provider does not touch A provider does not reach the bus, the job store, the audit log or the HTTP