Files
PiCloud/docs/versioning.md
MechaCat02 0473d295af feat: versioning scheme — lockstep crates + four independent surfaces
Establish how versions are assigned, bumped, and checked across the
five things that actually change for users: the product itself, the
Rhai SDK, the HTTP API, the database schema, and the inter-service
wire (reserved for cluster mode). Crates ship in lockstep — drift
between picloud-shared and picloud-manager-core is fiction since
they always release together — but surfaces are versioned and
checked at their natural boundaries.

  * docs/versioning.md is the authoritative reference: what gets a
    version, the per-surface compatibility rules, how each surface
    bump cascades to the product version (loose pre-1.0, strict
    post-1.0), and the five enforcement mechanisms (lockstep at
    compile time, /version at runtime, golden SDK contract tests,
    migration replay, CI guardrail).

  * shared::version exposes four constants — PRODUCT_VERSION (from
    CARGO_PKG_VERSION), SDK_VERSION ("1.0"), API_VERSION (1),
    WIRE_VERSION (1). Scripts read SDK_VERSION as ctx.sdk_version
    and can feature-detect against it.

  * Workspace inheritance: `[workspace.package] version = "0.2.0"`
    is the single point of truth; every crate uses
    `version.workspace = true`. dashboard/package.json mirrors.

  * Routes move to /api/v1/* — both control plane
    (/api/v1/admin/*) and data plane (/api/v1/execute/{id}).
    Picloud composes them via a single `/api/v{API_VERSION}` nest,
    so the next major is a copy-paste-and-bump. Caddyfile (dev and
    prod) routes /api/v1/* to picloud and 404s any other /api/*
    so old clients fail loudly instead of getting the SPA shell.
    Dashboard client + integration tests updated.

  * /healthz remains a plain "ok" string (k8s probes); /version is
    the new JSON endpoint returning every surface version in one
    place — product, sdk, api, schema (from
    manager-core::migrations::latest_version), wire.

  * Reasonable bump rationale: API path changes are breaking by
    definition, so 0.1.0 → 0.2.0 (pre-1.0 license to bump minor on
    any breaking change). SDK starts at 1.0 because scripts depend
    on it more strictly than the product depends on its internals;
    we'd rather promise SDK stability early than pull the rug.

Verified live:
  * /healthz → "ok" (plain text)
  * /version → {product:"0.2.0",sdk:"1.0",api:1,schema:1,wire:1}
  * /api/v1/admin/scripts → 200
  * /api/admin/scripts → 404 with error JSON (sunset major)
  * Script can read ctx.sdk_version → "1.0"
  * All 14 integration tests pass against new paths
  * 11 executor-core unit tests pass (added one for sdk_version
    exposure with the major.minor format invariant)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:31:08 +02:00

156 lines
8.0 KiB
Markdown

# Versioning
PiCloud carries **one product version** for the build you install, and **independent versions on the four contracts that actually break for users**. The product version answers "which build do I have"; surface versions answer "which contracts does that build honor".
This split exists because crate-level SemVer between, say, `picloud-shared` and `picloud-manager-core` is fiction — they always ship together. The boundaries that matter are user-facing: scripts depending on the SDK, callers hitting the HTTP API, databases shared across deploys, and (later) executor nodes talking to a manager.
---
## What gets a version
### Lockstep — one number for the whole thing
All of these carry the same version and are bumped together:
- Every crate in the Cargo workspace (via `version.workspace = true`)
- The dashboard's `package.json`
- Docker image tags (`picloud:0.2.0`)
- Git tags (`v0.2.0`)
Defined once in [`Cargo.toml`](../Cargo.toml) under `[workspace.package]`. There is no scenario where one crate is at a different version than another in the same build.
### Independent — versioned at each surface
| Surface | Where the version lives | Format | Bump rule |
|---|---|---|---|
| **Rhai SDK** | [`shared::version::SDK_VERSION`](../crates/shared/src/version.rs), exposed to scripts as `ctx.sdk_version` | `"major.minor"` string | Minor: additions; Major: removals/renames/retyped |
| **HTTP API** | URL prefix `/api/v{N}/...`; `shared::version::API_VERSION` is the current major | integer | New integer when request/response shape, status semantics, or auth model changes |
| **Database schema** | Largest applied migration ID (`manager-core::migrations::latest_version()`) | integer, monotonic | One per forward migration; never edit a committed file |
| **Inter-service wire** (cluster mode, v1.3+) | `X-PiCloud-Wire` request header; `shared::version::WIRE_VERSION` | integer | New integer when RPC shape changes |
All five live in one place so `/version` can return them honestly.
---
## Per-surface compatibility rules
### Rhai SDK (strictest)
Scripts run in production with no recompile. A wrong SDK bump silently breaks user code.
- **Patch** (`1.2.0 → 1.2.1`) — doc fixes, internal optimizations. No script-observable change.
- **Minor** (`1.2 → 1.3`) — added functions; added optional `ctx.*` fields; relaxed limits; new variants accepted alongside old ones. **Every script written for 1.2 must still run unchanged on 1.3.**
- **Major** (`1 → 2`) — anything removed, renamed, retyped, restricted, or made required.
Scripts can detect available features at runtime:
```rhai
if ctx.sdk_version >= "1.2" {
// call kv.* (added in 1.2)
}
```
The contract test in `crates/executor-core/tests/sdk_contract/` (coming alongside the first SDK additions) holds golden scripts that exercise every documented SDK surface. They must pass on every commit. A minor bump that breaks any of them is a build failure.
### HTTP API
Path prefix is the version. **Within a major**, the following are non-breaking and welcome:
- New endpoints
- New optional request fields
- New response fields (clients must ignore unknown fields)
- New `Deprecation:` headers warning of upcoming removals
The following require a new major (`/api/v2/...`):
- Removed endpoints, removed response fields, renamed fields
- Changed request-field types or required-field additions
- Changed status-code semantics for the same outcome
- Auth model changes
When `vN+1` ships, `vN` stays live for **at least one product minor** (so users have a release cycle to migrate). Deprecation is announced via the `Deprecation: true` and `Sunset: <date>` response headers on the old prefix before removal.
### Database schema
- **Forward-only.** Never edit a migration that has shipped. If a migration was wrong, write a new one that fixes it.
- Migrations are numbered sequentially (`0001_init.sql`, `0002_*.sql`, ...). The number is the schema version.
- A given binary applies migrations strictly greater than the last-applied ID, then refuses to start if its embedded migrations are *older* than what's in the DB — that would imply a downgrade, which is never automatic.
- This makes rolling deploys safe: the schema is always "ahead of or equal to" any running binary in the cluster.
### Wire protocol (cluster mode, v1.3+)
- Inter-service RPCs include `X-PiCloud-Wire: N`.
- A peer that doesn't recognize `N` refuses the call and returns `426 Upgrade Required` with the version it speaks.
- Both versions must be live in the cluster during rolling upgrades — current and current-minus-one — until all nodes agree on the new one.
---
## How we check and enforce
A versioning scheme without enforcement decays in months. Five cheap mechanical checks:
1. **Compile-time uniformity.** All workspace crates inherit `version.workspace = true`. Drift is impossible to introduce.
2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern.
3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test.
4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration).
5. **CI guardrail script.** A small diff-aware check that:
- Fails if `SDK_VERSION`'s major changed without a `CHANGELOG.md` breaking-change entry
- Fails if a new file appeared in `migrations/` that isn't the next sequential number
- Fails if a route handler removed or retyped a public field without a `BREAKING:` line in the commit message
(3) through (5) are wired in over the next few PRs; (1) and (2) land in the same commit as this document.
---
## When to bump what
The product version follows SemVer applied pragmatically — we're pre-1.0, so the rules are looser:
- **Patch** (`0.2.0 → 0.2.1`) — bug fixes, no surface change
- **Minor** (`0.2 → 0.3`) — any surface bump, new features, or breaking changes (pre-1.0 license)
- **Major** (`0 → 1`) — first stable release; SDK and API both committed to long-term compatibility
After `1.0`, the product version follows strict SemVer based on the *worst* surface change:
- Any surface major bump → product major bump
- Any surface minor bump → product minor bump (at minimum)
- No surface changes → product patch
A surface can hit its own `1.0` independently of the product. The SDK in particular is likely to stabilize before the platform does, since scripts in production demand it.
---
## Current versions
| | Version |
|---|---|
| Product | `0.2.0` |
| SDK | `1.0` |
| API | `1` |
| Schema | `1` (matches `migrations/0001_init.sql`) |
| Wire | `1` (reserved; cluster mode not implemented) |
Read live from `GET /version` on any running instance.
---
## Examples
**Adding a `kv.*` SDK in v1.1+:**
- Workspace bump: `0.2.0 → 0.3.0` (pre-1.0 minor)
- SDK bump: `"1.0" → "1.1"` (added functions only)
- API bump: none (no new endpoints affect existing API contract)
- Schema bump: `1 → 2` (`0002_kv_store.sql` adds the `kv_store` table)
**Renaming `ctx.execution_id` to `ctx.exec_id`:**
- SDK bump: `"1.x" → "2.0"` (breaking)
- Product: minor bump pre-1.0, major bump post-1.0
- Migration path: keep `ctx.execution_id` available in 1.x for a deprecation window, add `ctx.exec_id` alongside; flip to 2.0 only when both fields have shipped together for a release.
**Adding pagination to `GET /api/v1/admin/scripts`:**
- New optional `?limit=&offset=` query params with sensible defaults → no API bump
- Response keeps the same shape; clients that don't pass `limit` see the old behavior → no API bump
**Changing the response shape of `GET /api/v1/admin/scripts/{id}` to wrap in `{ script: {...} }`:**
- Breaking. Ship as `/api/v2/admin/scripts/{id}`. Keep `/api/v1` live until at least one product minor passes.