Skip to content

Fleet — On-Prem Deployment & REST API

Package Probe Fleet is the enterprise, self-hosted dashboard for org-wide security posture: it ingests scan reports from every machine, tracks seat activity, enforces org-scoped RBAC, and distributes central managed policy. It is a Go service backed by PostgreSQL, deployed on your own infrastructure (Docker Compose or Kubernetes/Helm) — your fleet data never leaves your environment.

Fleet is scoped by organization: each organization maps to one license, and scan reports, ingestion credentials, and audit events are tied to that tenant.

  • PostgreSQL — customer-managed (RDS, Cloud SQL, Aiven) or in-cluster. Fleet stores scan reports, machine heartbeat state, users, credentials, and audit events here.
  • A Package Probe license — the commercial source of truth; each Fleet organization maps to one license_id.
  • A reachable license-server (LICENSE_SERVER_URL) — Fleet requires it at startup and calls it to validate seats and machine ownership on every enroll/report/heartbeat. Air-gap mode only skips the seat-reclamation worker — it does not remove this requirement (see below), so a disconnected deployment still needs a license-server reachable from Fleet.
  • (Optional) Clerk — for SSO dashboard login. Fleet delegates SAML/OIDC login to Clerk (validating Clerk session JWTs) and consumes Clerk webhooks for user-lifecycle provisioning; it does not run a first-party SCIM server. Without Clerk, Fleet uses API-key auth.

The repository’s fleet/docker-compose.yml runs Fleet + PostgreSQL. Fleet requires LICENSE_SERVER_URL, LICENSE_SERVER_API_KEY, and FLEET_API_KEY — it refuses to start if any is missing. Set them and bring it up:

Terminal window
cd fleet
export FLEET_API_KEY="$(openssl rand -hex 32)" # dashboard/admin API key
export LICENSE_SERVER_URL="https://license.example.com" # seat + ownership checks
export LICENSE_SERVER_API_KEY="ls_..." # license-server auth
docker compose up -d

Fleet listens on port 8081 (http://localhost:8081), with a health check at GET /health. (The compose file supplies a DATABASE_URL for its bundled PostgreSQL and a default LICENSE_SERVER_URL, but you must still provide LICENSE_SERVER_API_KEY and FLEET_API_KEY.)

The chart at fleet/helm (packageprobe-fleet) is hardened for production — non-root, read-only root filesystem, all capabilities dropped, deny-by-default NetworkPolicy, resource requests + limits, and a PodDisruptionBudget when replicaCount > 1.

The chart natively models apiKey (→ FLEET_API_KEY), the database, retention, inactivity, air-gap, Clerk, and the license-server variables (licenseServer.url / licenseServer.apiKey / licenseServer.existingSecret.name → LICENSE_SERVER_URL / LICENSE_SERVER_API_KEY, PKG-780) — Fleet fatal-requires both at startup unconditionally (including in air-gap mode; see above), so the chart fails helm template/install/upgrade with a named error when neither is configured, instead of a silent render that crash-loops the pod after rollout. For the strongest secret posture, keep credentials in externally-managed Kubernetes Secrets the chart only references (database.existingSecret for the DB URL, licenseServer.existingSecret.name or the generic extraEnvFrom for the license-server variables, ideally driven by sealed-secrets / External Secrets / vault-injector) so the raw values never pass through Helm at all. If you’re already supplying LICENSE_SERVER_URL/LICENSE_SERVER_API_KEY via extraEnvFrom/extraEnv from before this chart modeled them natively, that keeps working unmodified — the chart can’t see inside an extraEnvFrom Secret’s keys, so it trusts a non-empty extraEnvFrom (or a same-named extraEnv entry) and skips its own guard rather than aborting an already-correctly-configured deployment. Note that any value you pass inline — via --set or a values file — is rendered into the Helm release Secret (and --set also leaks to shell history), so a values file is not itself a secret boundary. apiKey must be non-empty — the chart renders FLEET_API_KEY from it and fails template rendering if it is empty — so it necessarily passes through the chart’s own Secret; drive it from your secret tooling (e.g. an External Secrets-populated value) rather than committing it:

values.prod.yaml
apiKey: "REPLACE_ME" # required — the chart renders FLEET_API_KEY from this
database:
host: fleet-prod.cluster-xxxx.us-east-1.rds.amazonaws.com
name: fleet
sslmode: verify-full
existingSecret:
name: fleet-db # the Secret must contain a key literally named
key: DATABASE_URL # DATABASE_URL holding the full libpq URL
licenseServer:
url: "https://license.example.com"
existingSecret:
name: fleet-license-server # the Secret must contain a key literally named
# LICENSE_SERVER_API_KEY
# — or, equivalently, keep using the pre-PKG-780 escape hatch instead of the
# dedicated licenseServer.* fields above (either form works, not both):
# extraEnvFrom:
# - secretRef:
# name: fleet-license-server # Secret with LICENSE_SERVER_URL + LICENSE_SERVER_API_KEY
Terminal window
helm install fleet ./fleet/helm -f values.prod.yaml

The chart mounts existingSecret via envFrom, so the DATABASE_URL entry inside it must be keyed literally DATABASE_URL — a differently-named key is not remapped and Fleet exits with DATABASE_URL is required.

Fleet won’t start unless LICENSE_SERVER_URL, LICENSE_SERVER_API_KEY, and the Fleet API key are all present, plus a database. Database configuration has three forms:

  • databaseURL — a full libpq URL, e.g. injected by External Secrets Operator. Wins when set.
  • database.* — structured host / port / name / user / password / sslmode / extraParams that the chart assembles into a DATABASE_URL. Use sslmode: verify-full (the default) so TLS is verified, not just encrypted.
  • database.existingSecret.name — the chart skips generating DATABASE_URL entirely and mounts your Secret via envFrom. That Secret must contain the full libpq URL under a key named literally DATABASE_URL (the mount is a bare envFrom, so the key is not remapped — a differently-named key yields DATABASE_URL is required at startup) — not just a password, and the database.host/name/user/sslmode fields are ignored in this mode. This is the recommended production path (the URL lives outside the chart, managed by sealed-secrets / External Secrets / vault-injector).

replicaCount can be > 1: Fleet runs safely horizontally — sessions and background workers are stored/coordinated in PostgreSQL, so there is no per-process state to diverge across replicas.

Fleet is configured entirely via environment variables (the Helm chart maps its values to these).

VariableDefaultPurpose
PORT8081HTTP listen port.
DATABASE_URL—PostgreSQL libpq URL (required).
FLEET_API_KEY—Bearer key for the dashboard/admin API (required).
LICENSE_SERVER_URL—License-server base URL (seat + ownership checks).
LICENSE_SERVER_API_KEY—Auth for the license-server client.
INACTIVITY_DAYS60Auto-deactivate seats idle longer than this.
RETENTION_DAYS90Scan-report retention window.
MAX_REPORTS_PER_MACHINE1000Per-machine report cap.
FLEET_STALE_REPORT_THRESHOLD_HOURS48Flag a machine’s posture stale past this many hours. 0 disables staleness flagging.
AIR_GAP_MODEfalseSkip the daily seat-reclamation worker (see below). Does NOT remove the license-server startup requirement or the per-request ownership checks.
FLEET_LEGACY_INGEST_ENABLEDfalseAllow the legacy shared-key ingestion path (migration only).
FLEET_LEGACY_INGEST_DEADLINE—Refuse legacy ingestion at/after this instant.
CLERK_ENABLED / CLERK_SECRET_KEY / CLERK_PUBLISHABLE_KEY / CLERK_WEBHOOK_SECRET—Clerk SSO login + webhook-driven user-lifecycle provisioning (no first-party SCIM server).
SSO_DOMAIN_MAP—Map an email domain to a license, e.g. acme.com=lic-abc,corp.io=lic-xyz.
FLEET_GROUP_ROLE_MAP—Map an SSO group to a Fleet role.

Air-gap mode (AIR_GAP_MODE=true) disables the daily seat-reclamation worker (which would otherwise call the license-server to auto-deactivate idle seats). It does not remove the startup requirement for LICENSE_SERVER_URL / LICENSE_SERVER_API_KEY, and ingestion still performs machine-ownership checks against the license service. For a truly disconnected deployment, ensure the license-server is still reachable from Fleet (or run one alongside it).

Dashboard access is gated by three roles (ascending privilege):

RoleCan deactivate machinesCan manage configCan manage users
viewernonono
operatoryesnono
adminyesyesyes

A user’s role is set via the org-users API (default viewer), or mapped from an SSO group via FLEET_GROUP_ROLE_MAP. A request below the required role gets 403.

A Package Probe client (CLI or app) reports to Fleet through the alerts: block in ~/.packageprobe/config.yaml. The Fleet credential is a secret reference (env: / file:), never inline (an inline plaintext value is rejected at config load):

alerts:
fleet_endpoint: "https://fleet.example.com/api/v1/report"
fleet_credential: "file:/run/secrets/fleet_credential" # per-org credential (preferred)

The built-in reporter attaches the replay headers (X-Packageprobe-Request-ID, X-Packageprobe-Timestamp) and the machine identity Fleet requires. The preferred path uses a per-organization fleet_credential obtained from enrollment (below); a legacy shared fleet_api_key exists but the server’s shared-key path is disabled by default.

Fleet can also push a central managed policy back to opted-in clients (alerts.fleet_managed_policy: true), applied as the outermost admin layer. See Policy & registry configuration.

Endpoints that return data respond with JSON; write/liveness endpoints return a status code with no body (201 for a report, 204 for a heartbeat / machine deactivation / user deletion), and GET /health returns the plain text ok. Check the status code, and only parse a body where one is documented below. Two auth models:

  • Admin/dashboard API — Authorization: Bearer <FLEET_API_KEY> (constant-time compared).
  • Ingestion API (/report, /heartbeat, /policy) — a per-organization credential (Authorization: Bearer <credential>), plus the replay headers.
GET /health → 200 "ok" (no auth)

Ingestion (per-org credential + replay headers)

Section titled “Ingestion (per-org credential + replay headers)”

POST /api/v1/report and POST /api/v1/heartbeat require:

  • Authorization: Bearer <org-credential>
  • X-Packageprobe-Request-ID: <unique-per-credential> — usable once (replay guard).
  • X-Packageprobe-Timestamp: <RFC3339> — within 10 minutes of server time.

Fleet also verifies the report/heartbeat license_id matches the credential’s license, and that the machine belongs to that license (via the license-server). Request bodies are bounded (8 MiB for a report, 64 KiB for a heartbeat).

Method + pathPurpose
POST /api/v1/reportIngest a machine’s scan report (posture rollup). 201 on success.
POST /api/v1/heartbeatRecord a machine liveness ping (seat freshness). 204 on success.
POST /api/v1/enrollExchange an enrollment token for a per-org credential (no bearer; the token authenticates).
GET /api/v1/policyFetch this credential’s own org managed policy (org derived from the credential — never a path param).

The scan report body (POST /api/v1/report) — license_id and machine_id are required. On the per-org credential path machine_id is the machine’s hardware fingerprint (it’s what Fleet validates for machine ownership):

{
"license_id": "lic_123",
"machine_id": "<hardware-fingerprint>",
"fingerprint": "<hardware-fingerprint>",
"hostname": "dev-macbook",
"project_count": 12,
"dep_count": 843,
"vuln_count": 5,
"kev_count": 1,
"critical_count": 0,
"high_count": 2,
"medium_count": 3,
"low_count": 0,
"vex_suppressed_count": 1,
"policy_pass": false,
"policy_violations": 2
}

Dashboard / admin API (Authorization: Bearer <FLEET_API_KEY>)

Section titled “Dashboard / admin API (Authorization: Bearer <FLEET_API_KEY>)”
Method + pathPurpose
POST /api/v1/organizationsCreate/upsert an organization for a license.
GET /api/v1/organizations/{orgID}/usersList org users.
POST /api/v1/organizations/{orgID}/usersAdd/update a user + role.
DELETE /api/v1/organizations/{orgID}/users/{email}Remove a user.
GET /api/v1/organizations/{orgID}/policyRead the org’s managed policy YAML.
PUT /api/v1/organizations/{orgID}/policySet the org’s managed policy YAML.
POST /api/v1/organizations/{orgID}/enrollment-tokensMint an enrollment token (for MDM rollout).
POST /api/v1/organizations/{orgID}/credentialsMint a per-org ingestion credential.
GET /api/v1/licenses/{licenseID}License details + seat usage.
GET /api/v1/licenses/{licenseID}/machinesMachines on a license.
DELETE /api/v1/licenses/{licenseID}/machines/{machineID}Deactivate a machine (free a seat).
GET /api/v1/licenses/{licenseID}/summaryAggregated posture across the fleet — see Reading the compliance rate.
GET /api/v1/licenses/{licenseID}/machines/summaryLatest scan per machine.
GET /api/v1/licenses/{licenseID}/vulnsOrg-wide deduped CVE rollup (JSON, or CSV with ?format=csv) — see Exporting the CVE rollup.

The summary response carries two compliance rates, and they mean different things. Pick deliberately.

FieldDenominatorUse it when
policy_pass_rateAll reported machines — a machine with no policy configured counts as not passingYou want a single conservative number and will not render a qualifier
policy_pass_rate_evaluatedOnly machines that carry a policy verdictYou will also render machines_policy_evaluated / machines_no_policy
machines_policy_evaluated—The denominator policy_pass_rate_evaluated was computed over
machines_no_policy—Reported machines excluded from that rate

Two rules keep a rendering honest:

  1. Never show policy_pass_rate_evaluated without its denominator. A fleet where nothing was evaluated and a fleet where everything failed both report 0. Only machines_policy_evaluated separates them, and presenting the bare rate turns “nothing was checked” into “everything failed” — an unknown shown as a verdict.
  2. Do not treat policy_pass_rate_evaluated as fleet-wide. A fleet of seven machines with two evaluated and both passing reports 1.0. That is 100% of what was measured, not 100% of the fleet. Rendering it unqualified is the inverse error: a partial measurement presented as complete.

policy_pass_rate deliberately keeps its original, stricter meaning so existing v1 integrations do not silently start reporting partially-measured fleets as fully compliant. Both new fields are additive and stay on v1.

Machines that have never reported are not counted in machines_no_policy — they have their own machines_never_reported counter, so a qualifier that sums the two will not double-count.

GET /api/v1/licenses/{licenseID}/vulns returns the org-wide, deduped CVE rollup — the same rows the Vulnerabilities page shows — with each CVE’s blast radius (which machines, which projects, which dependency versions and their fixes). Add ?format=csv for a spreadsheet. ?sort=machines orders by blast radius; anything else uses the exploitability order — KEV first, then severity, then EPSS within each severity band, then blast radius.

Signed-in dashboard users can download the same CSV from the Download CSV button on the Vulnerabilities page — it scopes to their own session’s license, so no API key is needed.

Every export response is marked Cache-Control: private, no-store. Each tenant reads it from the same URL, so a shared cache in front of Fleet must never keep a copy.

This is the part that matters if the file is going into a compliance packet. A CVE list is only as complete as the fleet that produced it. The rollup can only count machines that reported, ran enrichment, scanned their whole ecosystem set, and did so recently. A machine that never reported is absent from every row — and absence looks exactly like “not affected”.

So the coverage qualifier travels with the data in four places, and you should render at least one of them anywhere you render the rows:

  • the coverage object in the JSON response;
  • #-prefixed key/value rows above the CSV table — present even when there are zero CVE rows, which is the only place the truth can live in an empty export;
  • fleet_coverage and fleet_coverage_detail, the two leftmost CSV columns, repeated on every row so the qualifier survives a sort, a filter, or five rows pasted into an email;
  • the X-Packageprobe-Coverage-State response header, for automation that archives the file without parsing it.
ValueWhat it means
COMPLETEEvery machine reported, was fully enriched, shipped the per-CVE detail its own count claims, scanned an un-narrowed set, and its report age was checked and found current. The only value under which an empty result may be presented as “no vulnerabilities”.
INCOMPLETEAt least one machine carries a measured gap. Treat the rows as a lower bound.
FRESHNESS_UNKNOWNNothing measured as wrong, but report age was never checked, so how current the rows are is unknown.
UNKNOWNNo machine has any posture — nothing is enrolled, or nothing has ever reported.

Alongside it, all_clear_claimable is the actual verdict — complete coverage and zero findings. A fully-measured fleet that has vulnerabilities reports state: "COMPLETE" with all_clear_claimable: false. Use state to ask “was the measurement whole?” and all_clear_claimable to ask “is it safe to say nothing was found?”.

One more counter worth knowing: machines_findings_missing counts machines whose own vulnerability count is higher than the per-CVE detail they sent. Their findings cannot appear in the rows at all, so the export marks itself incomplete rather than quietly omitting them.

machines_with_gaps is the number to show next to total_machines — “4 of 7 machines were not fully measured”. Do not add up the per-reason counters to get it. They overlap on purpose: one machine can be narrowed and stale and partially covered, so the sum over-counts. The per-reason counters (machines_never_reported, machines_reported_without_enrichment, machines_partially_covered, machines_narrowed, machines_stale) are for explaining why, on their own lines.

One counter needs its own warning: machines_stale: 0 does not mean the fleet is current. With the staleness threshold disabled it is zero because nothing was measured. Check freshness_evaluated before reading it.

And one boundary to know: total_machines counts the machines Fleet has a record of — those that have reported, plus those that finished enrolling. A machine activated against your license that has never enrolled with Fleet is not in this denominator, so it cannot appear in the gap counters either. If your seat count and total_machines disagree, that difference is machines Fleet has never heard from.

Every successful export — API or dashboard — writes a vulns.exported row to the Fleet audit log with the actor, format, row count, and the coverage state the file carried, so a compliance packet can be traced back to who pulled it and what it claimed. The row is filed under the tenant’s organization, so it appears in that org’s audit view and is removed by its data-deletion request.

Two notes on license identifiers. The {licenseID} in the API path must be a canonical UUID — anything else returns 400, because on a legacy deployment that value can be a raw license key and a key in a URL ends up in proxy logs. If yours is not a UUID, use the dashboard download instead: it takes the license from your session, so nothing goes in the path. And in the file itself, a non-UUID identifier is replaced by a stable ref: fingerprint — you can still match two exports of the same tenant, but no credential travels in a document you email to an auditor.

The epss field is omitted from a JSON row, and left as an empty CSV cell, when no client reported a score. That means the exploitation probability was never measured — it does not mean the probability is low. Do not substitute 0: a measured zero and an unmeasured one are different facts, and the export keeps them apart.

The same rule shapes the sort. EPSS ranks findings within a severity band rather than above it, and inside a band an unmeasured score sorts above a measured one. So an unscored CRITICAL never falls below a scored LOW, and a finding nobody has scored is surfaced ahead of one measured as unlikely.

Today no client sends a score, so the column is uniformly blank. That is the honest rendering of a signal that does not exist yet.

  • The file starts with a UTF-8 BOM so Excel renders non-ASCII hostnames and project names correctly. It sits on a # comment row, so it never appears in a data field.
  • Cells beginning with =, +, -, @, TAB, or CR are prefixed with '. Hostnames, project names and dependency names come from client machines, and without this guard a spreadsheet would evaluate them as formulas.
  • Multi-valued cells join with ; . A dependency with no known fix says (no known fix) rather than leaving the cell blank.
  • An unrecognised ?format returns 400 rather than quietly returning a different format.

The recommended way to give a machine (or CI runner) a scoped ingestion credential is the enrollment flow.

  1. Create the organization (admin):

    Terminal window
    curl -X POST https://fleet.example.com/api/v1/organizations \
    -H "Authorization: Bearer $FLEET_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"Acme Security","license_id":"lic_123","email_domain":"acme.com"}'
  2. Mint an enrollment token (admin — one token can enroll many machines, e.g. a Jamf/Intune rollout):

    Terminal window
    curl -X POST https://fleet.example.com/api/v1/organizations/org_x/enrollment-tokens \
    -H "Authorization: Bearer $FLEET_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"label":"jamf rollout","ttl_days":30}'
  3. Enroll the machine (client — the token authenticates; the response returns the per-org credential + the report/heartbeat URLs):

    Terminal window
    curl -X POST https://fleet.example.com/api/v1/enroll \
    -H "Content-Type: application/json" \
    -d '{"enrollment_token":"enr_x","machine_id":"mach_123","fingerprint":"hardware-fingerprint","hostname":"dev-macbook","owner_email":"dev@acme.com"}'
  4. Report — post scan reports with the enrolled credential + replay headers:

    On the per-org credential path, the report’s machine_id is the machine’s hardware fingerprint — Fleet validates machine_id against the license-server’s enrolled fingerprints, so a divergent placeholder is rejected with 403 machine does not belong to license. To have a zero-vuln report render as a clean all-clear (rather than “not checked”), set enrichment_attempted and enrichment_fully_covered to true — both default to false, and a report without them is recorded as not checked, not clean:

    Terminal window
    curl -X POST https://fleet.example.com/api/v1/report \
    -H "Authorization: Bearer $FLEET_CREDENTIAL" \
    -H "X-Packageprobe-Request-ID: $(uuidgen)" \
    -H "X-Packageprobe-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    -H "Content-Type: application/json" \
    -d '{"license_id":"lic_123","machine_id":"<hardware-fingerprint>","fingerprint":"<hardware-fingerprint>","hostname":"ci-runner","vuln_count":0,"policy_pass":true,"enrichment_attempted":true,"enrichment_fully_covered":true}'

Enrollment (steps 1–3) is an operator/admin flow — the Package Probe client does not exchange enrollment tokens or call /api/v1/enroll itself. Once you have a per-org credential (from step 3, or minted directly via the credentials API), configure it as alerts.fleet_credential and the built-in reporter handles step 4 — posting each scan report to alerts.fleet_endpoint with the required replay headers and machine identity. In a CI pipeline, store the credential as a secret (FLEET_CREDENTIAL) and either let the configured reporter send the report, or curl the POST /api/v1/report call above yourself.

For an alternative single global path, the legacy shared FLEET_API_KEY ingestion route remains available for controlled internal migration and air-gapped testing — enable it with FLEET_LEGACY_INGEST_ENABLED=true (it is off by default).

  • Auto-deactivation. A daily worker reclaims seats whose machines have gone quiet longer than INACTIVITY_DAYS, keyed off Fleet’s own heartbeat store. A machine that is actively heartbeating is never reclaimed; a heartbeat that lands during a sweep wins; deactivation is idempotent. Disabled in air-gap mode.
  • Coverage honesty. A machine’s zero-vulnerability count is painted clean only when its latest scan affirmatively ran enrichment and covered every dependency. A machine whose report shows enrichment that never ran or only partially covered its dependencies gets an honest “not checked” / “coverage incomplete” chip (and its count isn’t green), so an empty fleet is never mistaken for a clean one. A scan that fails outright (an incomplete discovery, or fully-degraded enrichment) is not reported at all — that machine simply goes quiet and surfaces as stale, never as a false all-clear.

A machine’s declared owner is the identity Fleet’s deprovisioning chain hangs off: scan reports resolve their reporting user from it, the user↔machine link is written from it, and offboarding reclaims seats through those links. Fleet therefore records where each ownership claim came from as well as what it says.

ProvenanceOriginDrives offboarding?
enrollmentDeclared at POST /api/v1/enroll, gated by an admin-issued enrollment tokenYes
adminConfirmed by a Fleet admin on the machine-detail pageYes
legacy_heartbeatWritten by a heartbeat — a self-report from the machineNo
(absent)Origin never established (a row predating this field, or a superseded claim)No

Only an administratively-asserted claim establishes a link. A heartbeat is authenticated by an organization-scoped credential, so a claim it makes is a self-report — any machine holding that credential could name any owner for itself, either attaching itself to a colleague (whose offboarding would then reclaim this machine’s live seat) or naming a stranger to escape its real owner’s offboarding. Heartbeats have not been able to declare ownership since the enrollment path became the sole declaring act.

An unverified claim is still displayed, badged unverified owner, because a machine that is silently not draining must not look like one with no owner. An admin resolves it by re-enrolling the machine (which supersedes every prior claim on that hardware and is the only way to change who owns it) or by confirming the address shown, which only applies if it still matches what is stored — a stale page, or a machine with conflicting claims, is refused rather than blessed; a confirmation and the user↔machine link it implies are applied together, so a machine never ends up looking confirmed while nothing can drain it.

Links that were already stored before this field existed are re-checked when a user is offboarded. Only a link with no ownership claim behind it, or one whose machine has a single administratively-asserted owner matching the linked user, is acted on; anything else — a self-reported claim, a stale link naming someone else, or a machine with conflicting claims — is removed without reclaiming its seat and recorded in the audit trail, so an old or invalid link cannot deactivate a machine that may not belong to the departing user.

MachineSummary responses carry the claim’s provenance as owner_source ("enrollment", "admin", "legacy_heartbeat", or "unknown"; "unknown" is a stated absence, never a verdict that the claim is trustworthy).

Fleet records audit events for organization changes, enrollment-token and credential creation, machine enrollment, heartbeat submission, scan-report ingestion, role/user changes, managed-policy changes, and machine ownership claims (a refused auto-link, an admin confirmation, a confirmation that matched nothing, and an offboarding that declined to reclaim a seat are each recorded). License and machine deactivation are audited in the license-server.

Fleet uses expand/contract schema migrations, so a rolling upgrade stays available: deploy the new version, which applies additive migrations, and (with replicaCount > 1 + maxUnavailable: 0) the API stays up throughout. To roll back, redeploy the previous image; because migrations are additive, the prior version reads the newer schema. For customer-hosted deployments, see the repository’s enterprise operations and upgrade/rollback runbooks (docs/enterprise-fleet-management.md, docs/upgrade-and-rollback.md).