Commands
The command accepts
--json.
orbit doctor
Verify one Node or every Node the caller may address, optionally limited to a set of families.summary.nodes, summary.families, summary.checks, summary.drift, summary.unverifiable, and summary.informational, whether the fleet is healthy, and the request ID. JSON output returns the same report and the same six summary counts.
The exit status follows the report.
The Gateway answers a completed report with HTTP 200, also when it holds drift or unverifiable findings. Transport and authorization failures use the normal error envelope.
Doctor selects only Nodes that the caller may address through an access grant. The Gateway refuses a
--node value outside that set with node_access.required, so Doctor never inspects a Node that the caller could not otherwise reach.How the Gateway inspects
The Gateway observes each Node once over SSH before it runs the family probes. The observation holds reachability, platform, architecture, and whether the WireGuard address is present. For managed Linux Nodes, it also holds the Node agent binary, unit, state, and checksums, plus free space and free inodes on the filesystem holding the Orbit home or root. macOS inspection uses native platform and tunnel checks. A missing Linux agent, systemd unit, exporter, or Linux disk path is not drift and not unverifiable. macOS does check free space on the enrolled account’s home volume, as node.disk_low describes. A failed SSH command makes the Node unreachable, and thenode family reports node.ssh_unreachable. A malformed or truncated observation makes the node family report node.inspection_failed instead. In both cases the other families treat the Node as unreachable. Database-only checks still run: Node and role lifecycle, role conflicts, Route status, and database connections. Each other family with resources on that Node reports one *.node_unreachable issue instead of its checks. The role family does this only when the Node has an active role. The route family does this only when the Node serves a custom proxy Route. The tool family does this only when the Node has a Tool row. An unreachable Node does not run the inventory scan, so it has no tool.inventory_scan issue. With no Tool rows the tool family reports nothing.
For a nested Laravel root such as apps/site/public, Doctor uses the same application directory as the runtime. It checks that app’s environment, cached APP_URL, PHP-FPM working directory, and production release .env link; Git identity and checkout-layout checks still cover the whole repository. Doctor does not infer a different app directory from discovered files.
On app-prod, the expected pool.conf also holds the pool of each directory that a Route with a web root serves, so those pools are not drift. Doctor checks the release .env link and APP_URL of the default directory only.
Repository probes pass -c core.hooksPath=/dev/null and -c core.fsmonitor=false to Git. Doctor does not use a checkout’s hooks or custom filesystem monitor while checking repository identity and source metadata. It leaves the stored Git configuration unchanged.
Checks that read root-owned files run through passwordless sudo on the Node. Every check that reads the live Caddyfile is one of them, because Orbit publishes each Caddyfile version under /etc/caddy/orbit-versions as root:caddy with mode 0750.
Families run in this order, whatever filter you pass: node, role, project, instance, schedule, tool, process, firewall, database_connection, route. Nodes sort by name and then ID. Resources sort by ID. So two runs against the same state produce the same report.
Each issue carries a stable code, a kind of informational, drift, or unverifiable, the resource type, ID, and name, a summary, and bounded expected and observed values. expected and observed stay a boolean, a string of at most 255 characters, or null.
checked counts the family’s persisted resources on that Node, not commands and not discoveries. For the tool family, that is the Tool rows, including failed rows. Unregistered packages and inventory-scan issues do not increase it. A tool family with no Tool rows still has checked 0 when it reports discoveries or a failed scan.
summary.checks is the sum of checked. summary.drift, summary.unverifiable, and summary.informational count issues of those kinds. An informational issue is not also counted as drift or unverifiable. summary.nodes and summary.families count the selected Nodes and family reports.
A family with no issues, or only informational issues, is healthy. A family with a drift issue and no unverifiable issue is drift. Any unverifiable issue makes the family unverifiable. A Node is healthy when every selected family is healthy. The report is healthy when every Node is healthy. Health ignores informational issues.
The report never contains raw output, commands, paths, URLs, branch names, repository origins, credentials, setting values, private keys, environment values, configuration contents, or exception text. A Route issue names the Route’s domain as its resource name.
Informational package discoveries
The tool family reads installed Homebrew formulae and casks and Vite+ global root packages through the shared package inspector. Registered Tools keep the drift checks below. A package with no matching Tool row is informational, not drift. Doctor creates no Tool rows, stores no inventory, and does not adopt. The scan states are the same five tokens astool:scan. complete contributes package findings only. unsupported contributes nothing, so a Linux Node does not report brew-cask as a false package. absent is one informational scan finding and no package findings. incomplete is one unverifiable scan finding even when the Node has no Tool rows, and the process exits 1. A read that fails before a scan state exists, including a command timeout, is one unverifiable tool.inspection_failed issue with a null resource id and name, even when the Node has no Tool rows. Doctor still returns the other families. conflicting is informational when no Tool row uses that manager, including a Linux Homebrew prefix Orbit does not own. It is unverifiable when a Tool row uses that manager, because Doctor cannot check those Tools against that scope. A conflicting scope with no Tools does not make the Node unhealthy.
The Resource column shows
tool, then the manager, then the package. For a package finding the manager is expected. For a scan finding the manager is resource_name. A formula and a cask that share a package name stay on different rows.
The package observed value is four parts in this order, joined by semicolons: version=14.1.1, dependency=no, adoption=supported, and block=none. dependency is yes or no. adoption is supported or unsupported. The version token is 1 to 64 characters from letters, digits, and ._+:-, or unknown when no such token exists. A null scan version is unknown, including a formula revision such as 25.8.1_1 and a cask version such as 3.003. block is none when adoption is supported. Otherwise it is protected, dependency, unsupported_artifact, unsupported_source, bottle_unavailable, version_unreadable, or authorization_required. An unsupported cask still uses tool.package_unregistered.
Registered Tool issues sort by Tool ID. Inventory scan issues follow, in the order brew, brew-cask, vp. Unregistered package issues follow, in that manager order and then by package name. Registered drift, including tool.not_installed on a failed row whose package was never installed, sets resource_id to the Tool id and leaves resource_name null. A report with only informational findings stays healthy and exits 0. Drift or unverifiable findings still exit 1.
Adopt one selected package through tool:adopt or the Node’s Tools page. The web app reads tool:scan for its package list. It does not store this report as an inventory.
What each family checks
node.disk_low is a drift issue when the checked filesystem has less than 10% free space, less than 2 GiB free space, or less than 10% free inodes. On Linux that filesystem is the one holding the Orbit home or root. On macOS it is the one that contains the enrolled account’s home directory, which is the volume for that account’s Homebrew prefix and Vite+ global store. Doctor reads the macOS volume with a native disk query, not a Linux path and not the Node agent. The finding reports the observed free space. If a filesystem does not provide inode totals, including APFS, Doctor still checks its free space and reports inode availability as unavailable rather than treating zero reported inodes as zero percent free. A macOS home volume that cannot be read is node.inspection_failed. Doctor only reports the issue; it never deletes files or frees space.
The project and instance families read the remote.origin.url value stored in each checkout. They ignore insteadOf rewrites from the Node’s Git configuration, so a Node that rewrites HTTPS URLs to SSH still matches. The project family compares that value by repository identity, so an SSH origin and an HTTPS Project URL for the same host and path match. A fork or another host still reports project.repository_origin_mismatch. The instance family compares the stored URL exactly. The project family looks for a development checkout under the Node’s apps root.
For a development Instance, instance.source_identity_mismatch means the checkout does not hold the recorded history: the recorded starting commit is missing, or HEAD shares no history with it. A checkout on another branch, on a detached HEAD, or reset below the starting commit is normal development work, not drift. Commands that act on the recorded branch check it when they run. For example, instance:destroy refuses a checkout on another branch with instance.source_branch_mismatch.
A public Route also reports instance.public_tls_mismatch when its served certificate is untrusted, invalid for the domain, or expires within one sixth of its lifetime; renew or repair the public certificate at the ingress. A Route mid-issuance can report a transient TLS issue until the certificate is ready.
The tool family compares only desired intent. A Tool without a version constraint needs only to be installed. With a constraint, Doctor checks the normalized installed version against it and reports tool.version_mismatch when the constraint rejects it. An invalid stored constraint, or a version that Doctor cannot normalize, reports tool.inspection_failed. Doctor never compares the version with the Tool’s installed_version, because that field records the last operation’s result, not intent.
Every Instance is expected to be active, except a task workspace that is not visitable. That workspace has no Route, so its expected final state is source_resolved; the tasks reference owns that rule. A non-null provisioning step is unfinished unless it is the terminal active step. A lifecycle state before the Instance’s settled state is also still provisioning. A non-null failed_step reports immediately; Doctor skips other unfinished provisioning while its row has changed within the last 20 minutes, and older provisioning reports only instance.provisioning_stuck. The project probe also excludes provisioning Instances; only the instance family reports when provisioning becomes stuck. A state later than the settled state is unexpected and reports instance.lifecycle_not_active with the expected state. The instance probe skips an Instance already in removing, except that removal lasting 10 minutes or more reports instance.removal_stuck; it also drops issues if removal starts or the row is deleted during inspection. The project probe excludes removing and unfinished-provisioning Instances when selecting eligible checkouts. The process probe skips Processes whose Instance is removing, and drops findings if removal starts or the Instance is deleted during inspection. The database_connection probe skips attachments whose Instance is removing, including when it rereads the attachment after inspection. No other family has a general removing-Instance skip. An unreachable Node skips the Instance lifecycle checks.
A role assignment that has stayed provisioning or removing for 11 minutes, while no operation holds the Node’s role lock, reports role.claim_stale instead of role.lifecycle_not_active: the operation that claimed it stopped without finishing, for example because its Gateway worker was killed. 11 minutes is the role lock’s 10-minute request term plus a 1-minute margin. The lock itself ends at most one term after the operation’s last command started, so a killed worker blocks the role for at most that term plus the 1-minute margin. orbit node:role:add <node> <role> --converge converges a stale provisioning claim again, and orbit node:role:remove removes a stale claim of either kind.
Every Route is expected to be active. Any other status reports route.lifecycle_not_active on the Node that serves the Route; the routes reference says which Node that is.
An ingress role needs Caddy running on its Node, because the Node’s Caddy serves its public sites. Doctor reports role.services_inactive when Caddy is stopped and role.firewall_projection_mismatch when the Ingress public HTTP rules differ.
A systemd Process desired running reports process.crash_loop with kind drift when its unit is activating with sub-state auto-restart, or its NRestarts count grows between observations in the same bounded, read-only inspection. This remains drift if a sample catches the unit as active between crashes. A nonzero restart count alone is not a crash loop, and ordinary activation without auto-restart or a growing count is not crash-loop evidence. The finding identifies the Process by ID and name, has expected=running, and includes the observed active state, sub-state, and restart counts when available in its bounded observed evidence. Doctor reports the crash loop instead of a second process.state_mismatch for the same observation. A Process desired stopped still follows the state-mismatch rule, not the crash-loop rule. Process runtime state owns these distinctions. Doctor only reports them; it does not restart the Process.
A sleeping development Instance is not drift. When the awake marker is absent, Doctor does not report process.state_mismatch for a Process that is desired running, not keep-alive, and observed stopped; the hibernation reference owns that rule. A prepared production home without a current link is healthy before its first deployment; the release layout reference owns that rule.
The schedule family reports schedule.orphan_artifact from a scan that needs SSH and passwordless sudo. Orbit manages sudo only on a Node with a role, so Doctor scans a roleless Node only while it hosts a Schedule.
The reference pages describe each family’s comparison in more detail: Schedules, Tools, Database connections, Instance environment variables, custom proxy Routes, the Caddy release a role needs, and Caddy build drift.
Why it works this way
These reasons explain the design. Check them before you propose a change.Verify only
Doctor reports drift and never repairs it. A second convergence engine would compete with the commands that own each change. So Doctor uses only database reads and read-only commands. Its inspectors stay separate from the managers that change Nodes, and Doctor never calls a mutating manager or action. Code that both sides need, such as a catalog, a parser, or a renderer, moves to a shared place. Repair stays with those commands, such asnode:role:add --converge. Restore, adoption, and interactive repair stay outside Doctor. Read-only Homebrew and Vite+ package discovery is the explicit exception to checking registered resources only. Other unmanaged host-state scans remain outside its contract.
Informational findings stay healthy
An unregistered Homebrew or Vite+ package is information. Selected ownership leaves packages outside Orbit on purpose. The finding names the manager, package, version when one exists, dependency status, and whether adoption is supported. It does not make the family or the Node unhealthy, and it does not change the exit code. Marking every unmanaged package as drift was rejected because that would call a chosen boundary a failure. Hiding the packages was rejected because the operator then has no list to adopt from. Adopting them inside Doctor was rejected because a read would change ownership. Treating a failed scan as an empty inventory was rejected because that would report success without checking the host. A Linux Node with no Tools can be healthy while absentbrew and vp scopes are informational, and an unmanaged Homebrew dependency stays informational beside a healthy Tool. On a Mac, those findings stay informational when node.disk_low makes the Node drift. Drift and unverifiable findings still exit 1. Doctor stores no inventory. The Tools page reads the scan and adopts through the adoption operation.
No stored reports
Doctor keeps no reports, raw findings, or raw observations for the caller. The request’s one Activity row is the only record of that request. Stored raw output exposes paths, configuration, or credentials. The outer loop stores the issue code, the resource, and the bounded expected and observed values. It does not store the raw report.One observation per Node
One bounded observation limits remote work. Every family then uses the same reachability and identity facts for that Node.A closed family list
Each family is an explicit probe with its own issue codes, order, and tests. There is no plugin registry. An architecture test requires each Gateway model to be a checked resource, a typed input, or excluded. A new model fails that test until its Doctor role is explicit.Bounded values
Issue values are booleans, enums, normalized versions, or fixed labels. This keeps secrets and diagnostic data out of the report. An unknown internal code becomes the family’s*.inspection_failed issue.