Skip to content

Latest commit

 

History

History
387 lines (338 loc) · 19.7 KB

CONFORMANCE.md

File metadata and controls

387 lines (338 loc) · 19.7 KB

Astrid Station protocol and conformance contract

Status: normative contract, protocol revision 1.

This document is the implementation-neutral contract for an Astrid Station. Astrid, AOS, and a third-party Station may implement the protocol independently. A client MUST treat a Station as a configured trust domain; the protocol does not imply that one Station is authoritative for another.

The JSON vectors in tests/conformance/fixtures/ exercise the rules below. scripts/station_conformance.py checks the structural and state-machine rules. Signature verification, key rotation, and threshold policy remain the responsibility of the implementation's TUF library; the vectors deliberately contain no invented signatures.

1. Terminology and scope

  • Station is a curated, append-only publication log plus its signed static metadata. The official Astrid Station and the AOS Station are different trust domains even when they point at the same bytes.
  • Source is a client configuration for one Station: an immutable station_id, base URL, and trusted TUF root fingerprint.
  • Coordinate is (namespace, capsule, version). The sealed publication digest includes station_id, and (station_id, coordinate) is its scoped identity.
  • Publication record is the immutable description of one accepted capsule publication. A record is never edited or deleted.
  • Event is an append-only state transition addressed to a publication digest. Events derive lifecycle state; they never rewrite the record.
  • Mirror is an additional byte locator for the exact artifact digest. A mirror event cannot replace, mutate, or introduce an artifact.
  • Publisher authority is the authority to declare the requested manifest, WIT, dependencies, provenance, and capabilities. Local capability authority is the installing operator's policy; it may deny a publisher request, but it may not grant a capability absent from the record.

The protocol is content-addressed. A GitHub Release URL, Pages URL, commit URL, or any other locator is transport only; a digest is the identity of the bytes.

2. Names, versions, and digests

namespace and capsule name are lower-case ASCII names matching [a-z][a-z0-9-]{0,62} and not ending in -. They MUST NOT be empty, contain ., _, /, \\, or an uppercase character. Names are compared byte-for-byte after validation; no Unicode or case folding is performed. A station_id is a lower-case ASCII token whose first byte is alphanumeric and whose remaining bytes are alphanumeric, -, _, or . (except . and ..).

version is canonical SemVer 2.0.0 without build metadata: MAJOR.MINOR.PATCH with optional hyphenated prerelease identifiers. Numeric identifiers have no leading zero. A +build suffix is rejected in v1 so every implementation compares and serializes the same coordinate.

Every tagged digest is lower-case and uses one of sha256 (64 hex), sha384 (96 hex), sha512 (128 hex), or blake3 (64 hex). TUF/root fingerprints use tagged sha256; publication identity uses tagged blake3.

3. Publication record

A record has exactly these keys (unknown keys are rejected):

{
  "schema": "station-v1",
  "station_id": "astrid",
  "coordinate": {"namespace": "astrid", "name": "hello"},
  "version": "1.2.3",
  "artifact": {
    "digests": ["sha256:<64 lower-case hex>", "blake3:<64 lower-case hex>"],
    "size": 4096,
    "media_type": "application/vnd.astrid.capsule",
    "locations": ["https://…"]
  },
  "package": {
    "embedded_identity": {
      "coordinate": {"namespace": "astrid", "name": "hello"},
      "version": "1.2.3",
      "package_digest": "blake3:<64 lower-case hex>"
    },
    "manifest_digest": "blake3:<64 lower-case hex>",
    "component_digest": "blake3:<64 lower-case hex>",
    "wit_digest": "blake3:<64 lower-case hex>",
    "capability_digest": "blake3:<64 lower-case hex>",
    "ipc_digest": "blake3:<64 lower-case hex>",
    "runtime_abi_digest": "blake3:<64 lower-case hex>",
    "dependency_digest": "blake3:<64 lower-case hex>",
    "capabilities": ["astrid:read"],
    "dependencies": [{
      "coordinate": {"namespace": "astrid", "name": "dep"},
      "requirement": "*", "optional": false
    }],
    "runtime": {
      "runtime": "wasmtime", "abi": "component-model-0.2",
      "digest": "blake3:<64 lower-case hex>"
    }
  },
  "publisher": {
    "identity": "github:astrid-runtime/hello",
    "signing_key": "blake3:<64 lower-case hex>"
  },
  "source": {
    "repository_url": "https://github.com/org/repo",
    "github_owner_id": 123456,
    "github_repository_id": 987654,
    "commit": "<40 or 64 lower-case hex>",
    "tree": "<40 or 64 lower-case hex>",
    "tag": "v1.2.3",
    "subdirectory": "capsules/hello",
    "source_digest": "blake3:<64 lower-case hex>"
  },
  "provenance": {
    "predicate_type": "https://slsa.dev/provenance/v1",
    "statement_digest": "blake3:<64 lower-case hex>",
    "builder_identity": "https://github.com/org/repo/actions",
    "attestation_identity": "sigstore:rekor:entry-1"
  },
  "metadata": {"channel": "stable"},
  "publication_digest": "blake3:<64 lower-case hex>"
}

The artifact locations and repository URL MUST use https; credentials, queries, fragments, percent escapes, ports, and path traversal are not permitted. size is a non-negative integer. digests is a non-empty, unique array of tagged digests in the Rust Digest order (sha256, sha384, sha512, blake3); locations are unique and sorted by UTF-8 byte order. The package fields are the typed EmbeddedPackageIdentity, manifest, Component Model, WIT, capability declaration, effective IPC, runtime/ABI, and dependency commitments plus their claims arrays. runtime_abi_digest MUST equal package.runtime.digest. Publisher identity and signing key, source repository IDs and revision/tree/tag/subdirectory/source digest, metadata, and provenance/attestation identities are all immutable.

3.1 Derived package commitments

component_digest is the digest of the complete component set, not one entry. Preparation canonicalizes every [[component]].file as a safe UTF-8 relative path, rejects duplicate canonical paths, requires each referenced file to have the WebAssembly magic bytes, and byte-sorts by canonical path. Its projection is astrid:station:component-set:v1\0, then a little-endian u64 entry count, then for each entry its length-prefixed path, little-endian u64 byte length, and exact bytes. A capsule with no components is valid and commits to the same domain followed by a zero count.

wit_digest projects every archive entry at wit or below wit/, byte-sorted by archive path, using astrid:station:wit-claims:v1\0, a little-endian u64 entry count, then the same path/byte-length/bytes encoding. No WIT files is a valid domain-separated empty set. These empty-set rules are required for installable MCP and other non-WASM capsule kinds; opaque archive assets remain bound by the whole-artifact and manifest commitments.

Effective capability claims begin with root [capabilities] and exhaustively merge every component capability, matching Astrid CapabilitiesDef::merge_from: uplink, allow_persistent, and allow_prompt_injection are boolean ORs, and all eight arrays (net, kv, fs_read, fs_write, host_process, net_bind, net_connect, and identity) are unions that are byte-sorted and deduplicated. The declaration projection uses astrid:station:capability-claims:v1\0, field count 11, and this fixed order: uplink, net, kv, fs_read, fs_write, host_process, allow_persistent, net_bind, net_connect, identity, allow_prompt_injection. Each field name is length-prefixed; booleans are one byte and arrays are a u64 count followed by length-prefixed values. The cross-language goldens in station-tools bind multi-component, empty component/WIT, and effective-capability behavior.

When package.astrid-version is present it MUST exactly match the explicitly supplied runtime claim. When it is absent, preparation MUST require the explicit runtime string *; absence means no declared constraint, not an inferred runtime version.

The publication digest is the tagged BLAKE3 digest of the domain-separated, length-prefixed binary projection of every immutable field, in this exact Rust order: schema, station ID, coordinate namespace/name, version, artifact size/media type, sorted locations and digest vector, publisher actor/signing key, source URL/owner ID/repository ID/commit/tree/tag/optional subdirectory/ source digest, runtime/ABI/digest, embedded identity coordinate/version/ package digest, manifest/component/WIT digests, capability array and its declaration/effective-IPC digests, runtime/ABI/digest again, dependency edges and dependency digest, provenance predicate/statement/builder/attestation, then sorted metadata key/value pairs. The domain prefix is the UTF-8 bytes astrid:station:publication:v1\\0. Text is length-prefixed by its UTF-8 byte length (little-endian u64); digests carry their algorithm tag, byte length, and raw bytes. An optional subdirectory is encoded as a one-byte presence flag followed by text when present; dependency optional is one byte (0/1). This is the cross-language canonicalization boundary, not RFC 8785 JSON. A mirror URL, event actor, or lifecycle reason MUST NOT enter this projection.

4. Same-coordinate and idempotence rules

Within one Station, (namespace, capsule, version) is write-once forever:

  • The first accepted record reserves the coordinate and its publication digest.
  • A resubmission with the same canonical immutable projection and digest is an idempotent success (already_published is not an error).
  • A resubmission with any different immutable field, including artifact, package, publisher/signing identity, source revision, provenance, or locator, is equivocation and MUST be rejected permanently. There is no administrator override and no in-place correction; publish a new version.
  • The same coordinate may exist in another Station. A client MUST scope lookup, locks, caches, and events by station_id; a matching coordinate in AOS cannot satisfy an Astrid source. The sealed station_id inside the record MUST match the Station identity in which it is stored.

5. Events and lifecycle

StationEvent is the append-only publication body. Its compatibility wire uses Serde's externally tagged enum: each body object has exactly one variant key (Yank, Unyank, Deprecate, Revoke, Tombstone, OwnerChange, AddMirror, AddAttestation, or Annotation) whose value is that variant's object. Every payload carries an actor and a publication key (station_id, coordinate, and version).

The authoritative v1 wire is EventEnvelope (schema: "station-event-v1") with exactly schema, station (station_id and trust_root), sequence, recorded_at, actor, authorization (actor, evidence, signature_digest), prior_event_digest, body, and event_digest. body is externally tagged as {"Publication": <StationEvent>} or {"NamespaceTransfer": <typed transfer>}. Sequence starts at one and is contiguous; sequence one has no prior digest and later envelopes require one. recorded_at is canonical RFC 3339 UTC, and the envelope actor MUST match the body actor and authorization actor. event_digest is domain-separated BLAKE3 over the Rust length-prefixed projection with prefix astrid:station:event:v1\\0; signatures and threshold verification for authorization.signature_digest remain delegated to the Station/TUF layer. The projection order is schema/station ID/trust-root/sequence/time/actor, authorization actor/evidence/signature digest, prior-digest presence and value, then the body discriminator (publication or namespace-transfer) and the typed body projection. Optional strings and keys use a one-byte presence flag; publication event variant labels are lower-case (yank, add-mirror, etc.).

The lifecycle variants are:

  • Yank removes a version from new resolution; an existing lock may continue to install it with a warning.
  • Unyank restores only the original sealed publication and is invalid unless the current state is yanked and not revoked/tombstoned.
  • Revoke blocks new and locked installation. A forensic override, if an implementation exposes one, MUST be explicitly digest-bound and outside normal resolution.
  • Deprecate keeps a version resolvable and carries optional replacement and note fields.
  • Tombstone suppresses ordinary discovery while retaining the minimum transparency record. It is terminal for normal resolution.
  • AddMirror adds one HTTPS locator for the publication's already sealed artifact. It has no effect on lifecycle or publication identity.
  • OwnerChange, AddAttestation, and Annotation are append-only metadata events; they do not change the publication digest.

Events after a tombstone are invalid except AddMirror needed for retention. A revoked publication cannot be unrevoked. Duplicate mirrors or attestations, retargeted events, and transitions that violate these rules are rejected. The derived state is deterministic from the sealed record and the event prefix. Readers MUST NOT infer state from mutable GitHub labels or URLs. The lifecycle rules below apply to the verified envelope prefix; no mutable label or URL is authoritative.

6. TUF roles and freshness

A Station is served as static, sparse Pages content and is treated as an untrusted mirror. The client ships a trust root and verifies TUF signatures and thresholds using its TUF implementation. The protocol roles are:

  • root: offline trust anchors and role thresholds; rotation is a signed root transition and is never inferred from Pages content.
  • timestamp: short-lived metadata referring to one exact snapshot version, digest, and length.
  • snapshot: a consistent set of target metadata versions, digests, and lengths.
  • targets (including package-record metadata): signed references to records, event logs, and artifact metadata.

Clients MUST reject expired metadata, timestamp/snapshot reference mismatches, snapshot/targets mismatches, rollback to a version below the last trusted version, and a response that mixes generations. Freshness is checked at the caller's current time; conformance fixtures provide checked_at so tests are deterministic. The timestamp role's expiry is the anti-freeze boundary. Signature bytes and cryptographic threshold vectors are intentionally delegated to the TUF implementation.

7. Sparse Pages layout and mirrors

A Station MAY publish only the files needed for lookup. A conforming sparse layout is:

<base>/v1/<root-version>.root.json
<base>/v1/timestamp.json
<base>/v1/<snapshot-version>.snapshot.json
<base>/v1/<targets-version>.targets.json
<base>/v1/objects/<algorithm>/<digest-prefix>/<digest>.json
<base>/v1/shards/<identity-shard>.json
<base>/v1/search.json
<base>/v1/<sha256>.<target-path>

The signed snapshot/targets objects map coordinates and event shards to content-addressed objects; a client MUST NOT infer an object from an unsigned path. The versioned role filenames and timestamp.json are emitted by the astrid-station-tool sign-pages command; _tuf-input/ is an unsigned, temporary generation input and MUST NOT be published. A client obtains the stable station_id and pinned root fingerprint from its configured trust source, not from an unsigned station.json.

Every path component is a validated name, SemVer, or digest. Clients MUST reject path traversal, symlinked fixture/content files, and a response that is not covered by the signed metadata. Pages/CDN may be stale or eventually consistent; clients verify the signed snapshot and retry a bounded number of times. A successful publish means the merge/append is durable in the Station's authoritative repository, not that every Pages edge is immediately fresh.

Every accepted record MUST have its exact capsule bytes in the Station source at artifacts/blake3/<first-two-hex>/<64-hex>.capsule. The object is at most 50 MiB, its path and size MUST match the record, and every sealed artifact digest MUST verify. Artifact objects are append-only; deletion, mutation, misplacement, and unreferenced orphan objects are rejected. Generation copies the object to the corresponding v1/artifacts/... Pages path and includes that path as a signed TUF target. Publication preparation automatically adds the Astrid Station Pages locator to the sealed location set. Additional AddMirror events remain optional transport locators and never replace the Pages-owned object. All fetched bytes MUST still match the sealed digest set, size, and media type.

8. Sources, locks, and resolution

A source is:

{
  "station_id": "astrid",
  "base_url": "https://astrid-runtime.github.io/station/v1",
  "root_fingerprint": "sha256:<64 lower-case hex>"
}

The source's station_id, base URL, and root fingerprint are lock-bound. The Rust LockRecord wire stores exactly station_id, trust_root, coordinate, version, publication_digest, artifact_digests, artifact_size, artifact_media_type, manifest_digest, component_digest, wit_digest, capability_digest, ipc_digest, runtime_abi_digest, dependency_digest, provenance_digest, and source_digest. These fields bind every artifact and package/source commitment needed to reproduce the selected bytes; publisher identity and provenance identity remain available through the publication digest. Artifact locations are intentionally absent: they are transport, and effective mirror locations are derived from signed AddMirror state. A lock is valid only if every field matches the selected record and trusted root (trust_root is the tagged SHA-256 root fingerprint). Cache keys include station_id and publication digest.

Resolution is exact within one source: filter that source's records by the requested SemVer range, omit yanked/revoked/tombstoned records as required by their lifecycle, and select according to the client's documented SemVer precedence. A client MUST NOT silently fall back to another configured source, merge candidate sets from different sources, or substitute a same-coordinate record from AOS for Astrid. Cross-Station fallback requires an explicit user choice and a new source-bound lock.

9. Publisher and local capability authority

The publisher controls the signed record's requested capabilities, manifest, WIT, dependency graph, provenance, and source commit. The local operator (or fleet policy) controls whether those requests are granted in the installation environment. Local policy may deny, constrain, or require approval; it MUST NOT elevate the record or silently rewrite its publication digest. The kernel enforces the resulting local grant, while the Station records only publisher claims.

10. Eventual-consistency and failure contract

Readers MUST verify a complete signed generation before using any record. A Pages 404, stale generation, timeout, or incomplete shard is a retryable transport result only when the trusted metadata remains valid; it is never a reason to use another Station implicitly. Writers report the authoritative PR/ append result separately from edge propagation. Implementations SHOULD expose the observed snapshot version and publication digest so operators can diagnose stale edges without changing identity.

The conformance runner returns one deterministic JSON result for every fixture. It bounds fixture size, recursion, file count, subprocess time, and subprocess output; rejects symlinks and traversal; and reports stable error codes and JSON paths. An optional implementation command is invoked with --json, receives one fixture JSON document on stdin, and MUST return {"accepted": bool} as a single JSON object. The command is advisory to the structural checks and does not replace TUF cryptographic verification.