Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Accept strictly validated, identifier-free update outcomes in a separate, explicitly configured dataset; add a side-effect-free capability check while keeping production collection unbound. Thanks @roboclaw-bot, @fuller-stack-dev, and @vincentkoc.
- Refresh Wrangler, Cloudflare Worker types, and Vitest with the matching workerd runtime and npm lockfile.
- Discard malformed UTF-8 feature-statistics uploads instead of repairing and recording them, while preserving update responses.
- Keep offline exports outside their executing source checkout, linked worktrees, and input archives, including differently cased paths on case-insensitive filesystems.
- Remove the public statistics dashboard and `/api/stats` endpoint while preserving the privacy page, update checks, analytics recording, and data retention.
Expand Down
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,34 @@ written from a validated request.
| Route | Purpose |
| --- | --- |
| `GET \| POST /api/latest-version` | Returns `{ version, note? }`. `version` is the latest published OpenClaw release (looked up from the npm registry and cached at the edge for 5 minutes). `note` is an optional short message shown in the operator's terminal, used only when a release is worth acting on immediately. |
| `HEAD /api/latest-version` | Outcome capability only: empty 204 with `OpenClaw-Update-Results: 2` when its separate binding is present; otherwise empty 503 without that header. Always `Cache-Control: no-store`; no analytics, quota consumption or version lookup. |
| `GET /` | Human-readable page: what is collected, how to turn it off, without a public statistics dashboard. |

## Identifier-free update outcomes

The same POST endpoint also accepts a strict schema-2 `update_result` event from
a companion client implementation. These outcome reports use a **separate dataset**
and never include geography or daily feature/identity rows. All fields are required
public labels; unknown keys, invalid labels, malformed UTF-8 and bodies over 4096
bytes are rejected. This receiver change does not enable a client or deploy collection.
See [the wire contract, storage columns, retention and private aggregate SQL](docs/update-results.md).
The companion client reports outcomes **on by default**, like the existing update
ping, under update-request policy rather than optional feature-statistics consent.
`update.checkOnStart: false`, `OPENCLAW_NO_AUTO_UPDATE=1`, and Nix mode suppress
outcomes. A truthy `CI` always suppresses outcomes, including when a replacement
`OPENCLAW_TELEMETRY_ENDPOINT` is explicitly configured. `DO_NOT_TRACK` and
`openclaw telemetry off` control feature statistics, not update outcomes. Feature
statistics remain off by default. Deploy and verify this receiver and its separate
dataset **before releasing the default-on client**, under separately authorized
rollout. The default production configuration intentionally omits `UPDATE_RESULTS`,
so the existing main-push deployment cannot implicitly activate outcome collection.
Outcome attempts HEAD the same full configured endpoint and POST only after exact
204 plus `OpenClaw-Update-Results: 2`; old receivers (405) and unconfigured receivers
(503) never receive the outcome payload. Binding presence is capability, not proof
of production delivery. The daily GET and opt-in schema-1 POST remain single requests.
See the contract for the shared timeout, no-redirect and opt-out recheck requirements.
The daily-check behavior described below is unchanged.

## What an install sends

With automatic update checks enabled, OpenClaw reuses a successful version check for 24 hours.
Expand Down Expand Up @@ -72,7 +98,7 @@ each field; missing or invalid values are left empty without discarding valid fi

## What is stored

Each recorded request contributes one Analytics Engine data point with these columns and no others:
Each recorded daily update check contributes one Analytics Engine data point with these columns and no others (schema-2 outcome storage is documented separately above):

| Column | Value |
| --- | --- |
Expand All @@ -99,7 +125,7 @@ enablement. The session count depends on creation events still retained in a bou
Missing or unreadable state produces zero; this is not active sessions, messages, or all sessions
that existed that day.

Unknown keys in a request body are dropped rather than stored, so a future client cannot silently
Unknown keys in a schema-1 request body are dropped rather than stored, so a future client cannot silently
widen what this service keeps. User-Agents longer than 512 characters become an unknown identity
before parsing. Identity fields remain length-bounded and character-filtered. Feature IDs must be
complete identifiers of at most 64 characters; malformed or overlength IDs are dropped, never
Expand Down Expand Up @@ -169,12 +195,13 @@ processing is outside those settings.

| Command or setting | Effect |
| --- | --- |
| `openclaw telemetry off` | Stops anonymous feature statistics. Update checks continue. |
| `openclaw telemetry off` | Stops anonymous feature statistics. Update checks and default-on outcomes continue. |
| `DO_NOT_TRACK=1` | Same, enforced from the environment. |
| `update.checkOnStart: false` | Stops both tiers of automatic update requests. Explicit update commands and other configured services are separate. |
| `update.checkOnStart: false` | Stops automatic update requests and outcome reporting. Explicit update commands and other configured services are separate. |

`OPENCLAW_NO_AUTO_UPDATE=1` also prevents automatic update requests. A truthy `CI` suppresses both
tiers unless a replacement `OPENCLAW_TELEMETRY_ENDPOINT` is explicitly configured.
`OPENCLAW_NO_AUTO_UPDATE=1` also prevents automatic update requests. A truthy `CI` suppresses
daily checks and schema-1 feature reports unless a replacement `OPENCLAW_TELEMETRY_ENDPOINT`
is explicitly configured. Outcome reports remain suppressed in CI even with a replacement endpoint.

Disabling requests stops future automatic reports; it does not erase previously recorded rows.
The same three-month Analytics Engine retention applies. This receiver adds no backup or export job.
Expand Down
173 changes: 173 additions & 0 deletions docs/update-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Identifier-free update outcomes (schema 2)

This receiver accepts a terminal update outcome on the existing
`POST /api/latest-version` endpoint. It is separate from daily update checks and
schema-1 feature reports. This change does not enable a client, deploy the Worker,
provision a production dataset, or authorize production collection. The companion
client reports outcomes **on by default**, like the existing update ping, governed
by update-request policy rather than optional schema-1 feature statistics.
`update.checkOnStart: false`, `OPENCLAW_NO_AUTO_UPDATE=1`, and Nix mode suppress
outcome reports. A truthy `CI` always suppresses them, including when a replacement
`OPENCLAW_TELEMETRY_ENDPOINT` is explicitly configured. The daily-check custom-endpoint
exception does not apply to outcomes. `DO_NOT_TRACK=1`,
`openclaw telemetry off`, and `telemetry.enabled` control feature statistics, not
default-on update outcomes; feature statistics remain off by default.

The receiver and separate outcome dataset must be deployed and verified **before
releasing the default-on client**. That rollout needs separate authorization;
local tests do not establish production readiness. The default production
template deliberately **omits `UPDATE_RESULTS`**: the existing push-to-main
workflow may deploy receiver code, but must not implicitly activate collection.
A separately authorized rollout must explicitly add the `UPDATE_RESULTS`
binding for `openclaw_update_results`, preserving the existing `TELEMETRY`
binding, and verify retention and delivery before enabling production collection.
No deployment or provisioning is authorized by this PR.

## Capability handshake

An outcome attempt uses **two requests**, first `HEAD`, then (only when supported)
`POST`, to the same full configured `/api/latest-version` URL, including its
query. There is no new endpoint setting or fallback. Both requests use the fixed
`openclaw-update-result/1` User-Agent. Only exact status **204** with header
`OpenClaw-Update-Results: 2` permits the client to POST. Header names are
case-insensitive. Older receivers return 405; a receiver without the outcome
binding returns **503 without the capability header**. Neither gets outcome data.

HEAD has an empty body and `Cache-Control: no-store` for both 204 and 503. It
checks binding presence synchronously; it never reads an upload or geography,
uses the recording limiter, writes analytics (including sample points), or looks
up a version. It is a protocol capability check, **not production readiness or
successful storage proof**. The client shares a three-second timeout across both
requests, disallows redirects, and rechecks update-request opt-outs after the
HEAD await before sending. No retries or delayed queue are added. Daily GET and
opt-in schema-1 feature POST remain single requests and need no handshake.

## Wire contract

Send JSON with fixed User-Agent `openclaw-update-result/1` (not stored). All 18
fields in `test/fixtures/update-result.json` are mandatory; additional keys are
rejected, not ignored. The only numeric field is `schema: 2`; `event` is exactly
`update_result`. Other values must exactly match these case-sensitive labels:

| Field | Accepted values |
| --- | --- |
| outcome | succeeded, failed, rolled-back |
| fromVersion, targetVersion, resultingVersion, runningVersion | public release syntax below, or unknown |
| platform | linux, darwin, win32, freebsd, openbsd, unknown |
| arch | x64, arm64, arm, ia32, unknown |
| installMethod | git-checkout, npm-global, pnpm-global, bun-global, managed-service, unknown |
| channel | stable, beta, dev, extended-stable, unknown |
| duration | under-10s, under-1m, under-5m, under-30m, over-30m, unknown |
| postCheck | passed, failed, unknown |
| failedStage | requested, staging, validating, repairing, activating, restarting, verifying, unknown, none |
| errorCategory | permission, network, timeout, storage, other, none |
| errorCode | EACCES, EPERM, ENOSPC, ETIMEDOUT, ECONNRESET, ECONNREFUSED, ENOTFOUND, unknown, none |
| rollback | not-needed, not-attempted, succeeded, failed, unknown |
| recovery | safe, unsafe, unknown |

Public version syntax is
`/^202[0-9]\.(?:[1-9]|1[0-2])\.(?:0|[1-9][0-9]{0,5})(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/`,
matching the entire string (including rejecting trailing line terminators).
The patch component accepts zero or a non-zero-leading integer up to six digits
(0–999999), including extended-stable versions such as `2026.8.33` and
`2026.8.123`. Year, month, revision and beta-suffix restrictions are unchanged.
It excludes build metadata, commit SHAs and private prerelease labels. This is
syntax validation, not a claim that a label was actually published. `succeeded`
requires `failedStage`, `errorCategory` and `errorCode` all to be `none`.

Uploads must be valid UTF-8 and at most **4096 bytes**, including whitespace and
any BOM. The fixed UA selects the 4096-byte streaming cap before JSON decoding,
so malformed, absent or oversized outcome bodies never reach the legacy recorder.
A parsed `schema: 2` or `event: "update_result"` also selects strict validation
without that UA, using the exact byte count from the legacy bounded reader.
Without the fixed UA, undecodable bodies cannot be classified as outcomes and
retain legacy invalid-feature behavior; clients must always send the fixed UA.

Invalid outcomes return `400 {"error":"invalid_update_result"}` with no recording.
A missing outcome binding or synchronous analytics write failure returns
`503 {"error":"update_results_unavailable"}`; there is **no fallback** to the daily
dataset. Neither response echoes input or diagnostics. Accepted requests receive
the existing version response (or its existing `503 version_unavailable`). A
version failure can occur after recording; clients must not infer exactly-once
storage or retry to recover an acknowledgement. The existing per-IP recording
limiter runs exactly once before reading a GET/POST upload: exhausted callers
receive their version answer without reading or validating the body, even if
it never finishes, and without recording. Thus invalid/unavailable outcome
responses above apply only while recording quota is available. IP is only a transient limiter key, never an analytics column.

## Storage and privacy

Binding `UPDATE_RESULTS` writes exclusively to `openclaw_update_results`. The daily
`TELEMETRY` binding and `openclaw_telemetry` columns remain unchanged. Outcome
processing does not read `request.cf`, parse legacy identity, load the feature
vocabulary, or write any daily row. No IDs, raw User-Agent, geography, hostname,
path, command, arbitrary error text, logs or free-form strings are stored.
Worker observability and invocation logs stay disabled. Cloudflare still
processes connection metadata independently of these Worker storage rules.

The positional contract in `src/update-result.ts` is:

| Column | Value |
| --- | --- |
| index1 | targetVersion (sampling key, not an identifier) |
| blob1 | event |
| blob2 | outcome |
| blob3 | fromVersion |
| blob4 | targetVersion |
| blob5 | resultingVersion |
| blob6 | runningVersion |
| blob7 | platform |
| blob8 | arch |
| blob9 | installMethod |
| blob10 | channel |
| blob11 | duration |
| blob12 | postCheck |
| blob13 | failedStage |
| blob14 | errorCategory |
| blob15 | errorCode |
| blob16 | rollback |
| blob17 | recovery |
| double1 | schema (2) |

Analytics Engine adds its own receipt timestamp and sampling weight. Its published
retention is **three months**; this change adds no archive, backup or export job.
Seventeen blobs, one double and one bounded version index fit the published
limits. Operators must confirm retention and the separate binding before a
separately authorized rollout. No production binding was provisioned or verified
by local tests. References: Cloudflare Analytics Engine
[limits](https://developers.cloudflare.com/analytics/analytics-engine/limits/) and
[SQL API](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/),
checked September 19, 2026.

## Aggregate-only analysis

No public statistics or individual report route is added. Authorized operators
can use the private Analytics Engine SQL API for bounded aggregates, for example:

```sql
SELECT blob4 AS target_version, blob2 AS outcome,
SUM(_sample_interval) AS reports
FROM openclaw_update_results
WHERE timestamp > NOW() - INTERVAL '7' DAY AND double1 = 2
GROUP BY blob4, blob2
ORDER BY reports DESC
```

```sql
SELECT blob13 AS failed_stage, blob14 AS error_category,
SUM(_sample_interval) AS reports
FROM openclaw_update_results
WHERE timestamp > NOW() - INTERVAL '7' DAY
AND double1 = 2 AND blob2 != 'succeeded'
GROUP BY blob13, blob14
ORDER BY reports DESC
```

These are report counts, not unique installs, people or attempts. There are no
identifiers for deduplication or longitudinal joins. Missing reports, update-policy
opt-outs, Nix/CI suppression, NAT rate limits, unauthenticated spoofing and sampling
bias the counts.
Do not treat them as fleet-wide success rates, billing or security evidence.
Avoid individual-row exports or joins to daily geography; review any aggregate
publication separately for small groups. These SQL examples were not run against
production data.
Loading
Loading