| 1 | # Signed cloud facts |
| 2 | |
| 3 | Cloud facts are an optional signed overlay for model metadata and unset provider |
| 4 | model defaults. They are **off by default**. The production trust tables in Rust |
| 5 | and the website are empty, so enabling the setting currently reports an inert |
| 6 | layer and does not fetch or trust a production channel. |
| 7 | |
| 8 | This source slice does not establish a deployed endpoint, published database |
| 9 | row, signing-key custody, or a real provider request. The JSON under |
| 10 | `docs/cloud-facts/stable.json` is unsigned authoring material; its release entry |
| 11 | matches the checked-in `web/data/latest-published-release.json` receipt. It is |
| 12 | not a publication receipt. Public test fixtures establish local behavior only. |
| 13 | |
| 14 | Related: [catalog refresh](CATALOG_REFRESH.md) and [provider routes](PROVIDERS.md). |
| 15 | |
| 16 | ## Authority and configuration |
| 17 | |
| 18 | ```toml |
| 19 | [cloud_facts] |
| 20 | enabled = false |
| 21 | channel = "stable" |
| 22 | ttl_hours = 6 |
| 23 | ``` |
| 24 | |
| 25 | `CODEWHALE_CLOUD_FACTS=1|0` overrides the setting; |
| 26 | `CODEWHALE_DISABLE_CLOUD_FACTS=1` is the hard disable. Channel, URL and local |
| 27 | signed-envelope overrides are `CODEWHALE_CLOUD_FACTS_CHANNEL`, |
| 28 | `CODEWHALE_CLOUD_FACTS_URL` (optional `{channel}` placeholder) and |
| 29 | `CODEWHALE_CLOUD_FACTS_PATH`. A local file is still subject to all trust checks. |
| 30 | Production network refresh is suppressed in CI; tests opt into an explicit |
| 31 | loopback fixture transport policy. |
| 32 | |
| 33 | Loading a config object is structural. Accepted startup/reload settings admit |
| 34 | one process-wide source generation. A refresh captures that generation before |
| 35 | work and must still own it to publish either memory or disk state. Disabling or |
| 36 | changing the source invalidates earlier work, clears the prior overlay and |
| 37 | invalidates catalog readers. Hard disable also blocks local-file/cache reads, |
| 38 | new network work, cache writes and a late refresh's publication. |
| 39 | |
| 40 | Startup can read a bounded regular cache file and launch a background refresh; |
| 41 | network success is never a startup dependency. Missing, rejected, inapplicable |
| 42 | or expired facts leave the remaining catalog authorities usable. Status reports |
| 43 | whether facts are off, inert, verified, rejected or unavailable through the |
| 44 | existing compact catalog/status surface. |
| 45 | |
| 46 | ## Verification and expiration |
| 47 | |
| 48 | The `facts/v1` envelope contains exact base64 payload bytes, their SHA-256, |
| 49 | Ed25519 signatures and repeated metadata. The signed message is: |
| 50 | |
| 51 | ``` |
| 52 | "codewhale-facts/v1\0" || key_id || "\0" || payload_bytes |
| 53 | ``` |
| 54 | |
| 55 | Clients verify bounded envelope/payload sizes, supported envelope and algorithm, |
| 56 | an active pinned key, signature and digest, signed/outer metadata agreement, |
| 57 | channel, schema, semantic-version applicability and the accepted version floor. |
| 58 | The key ID participates in the signature. Rotation can carry extra signatures; |
| 59 | at least one active approved key must verify. A database key registry is not a |
| 60 | trust root. |
| 61 | |
| 62 | Publication, expiration and announcement dates must be valid UTC timestamps. |
| 63 | Future publications are rejected outside the bounded clock tolerance. Signed |
| 64 | expiry is never extended by a successful refresh or `304`. The client's stated |
| 65 | 48-hour expiry grace is included in the scoped validity bound; after that bound |
| 66 | facts are stale and cannot supply catalog prices or defaults. The public relay |
| 67 | rejects expired delivery. Per-item applicability and announcement windows are |
| 68 | re-evaluated when cached data is reused. |
| 69 | |
| 70 | A `304` authenticates nothing by itself: cached bytes must re-verify against the |
| 71 | current keys, channel, binary version, rollback floor and clock. Cache and ETag |
| 72 | identity are partitioned by source/channel, and channel rollback protection |
| 73 | survives a source change. HTTP bodies, outer disk cache records and labels are |
| 74 | bounded; cache/local readers reject symlinks, non-regular files, multiply linked |
| 75 | files and oversized input. A failed or untrusted response cannot become a new |
| 76 | catalog authority. |
| 77 | |
| 78 | ## Catalog and cost behavior |
| 79 | |
| 80 | The existing compiler inserts cloud facts at layer 15: |
| 81 | |
| 82 | ``` |
| 83 | 0 bundled Models.dev < 5 bundled Codewhale < 10 live Models.dev |
| 84 | < 15 verified cloud facts < 20 provider-owned live < 25 Codewhale account |
| 85 | < 30 config < 40 user overrides < policy DENY |
| 86 | ``` |
| 87 | |
| 88 | An upsert patches specified metadata fields. Creating a row requires either its |
| 89 | context window or an `allow_unlisted` assertion (below); an attested ID-only row |
| 90 | is created with every limit, price and capability **unknown** rather than |
| 91 | inferred from a sibling model or a lower stale layer. Deprecation annotates; |
| 92 | hide only removes lower bundled/Models.dev rows. Cloud data cannot delete |
| 93 | provider-live, account, config or user rows. |
| 94 | |
| 95 | **Which rows a patch reaches.** An upsert replaces fields on a row held at |
| 96 | layer 0, 5 or 10 — the bundled Models.dev seed, the bundled Codewhale snapshot, |
| 97 | and a live Models.dev refresh — and is skipped with a receipt on anything at |
| 98 | layer 20 and above. That reach is the point of the layer split: most models a |
| 99 | user sees are described by Models.dev rather than by the provider, so a stale |
| 100 | context window or a changed rate on such a model is exactly what a signed |
| 101 | correction exists to fix, without a reinstall. |
| 102 | |
| 103 | The distinction is what was *asked*, not what was fetched most recently. A |
| 104 | provider `/v1/models` answer is a fact about an endpoint the user |
| 105 | authenticated to, so it outranks a signed correction and is only ever |
| 106 | completed, never displaced. A Models.dev refresh is a public third-party |
| 107 | catalog that is merely fresher than the copy compiled into the binary, so it |
| 108 | is corrigible on the same terms as that copy. A refreshed row therefore carries |
| 109 | `CatalogSource::ModelsDevLive` and no endpoint fingerprint; only a provider |
| 110 | roster carries `CatalogSource::Live`. |
| 111 | |
| 112 | A provider `/v1/models` roster is authoritative for the IDs it lists **and for |
| 113 | its own omissions**. This client keeps no history of past rosters, so it cannot |
| 114 | tell a never-listed preview from a model the provider retired, and it does not |
| 115 | guess: no local layer — bundled, Models.dev, or anything else — is evidence |
| 116 | about what a provider once served. Without an explicit assertion the roster |
| 117 | stands, and a signed patch can never put an omitted ID back. |
| 118 | |
| 119 | `allow_unlisted` is that explicit assertion: a signed boolean on one model |
| 120 | patch, default false, meaning "this exact ID is available on this provider's |
| 121 | official endpoint even though the roster omits it". It is honored only on an |
| 122 | `upsert` and only in a payload that carries `not_after`, so the claim always |
| 123 | expires and has to be renewed by publishing rather than lived with. An older |
| 124 | client that predates the field deserializes it as false and simply keeps roster |
| 125 | dominance. The assertion grants nothing else: it does not bypass identity, |
| 126 | region, endpoint, account/OAuth entitlement, or user configuration precedence, |
| 127 | and it names one exact ID — no prefix, family or fallback. |
| 128 | |
| 129 | `hide` and `deprecate` act on a row the local catalog holds. An attested row is |
| 130 | retracted by dropping its upsert from the next payload or letting `not_after` |
| 131 | lapse. A failed or rejected request is never treated as evidence a model is |
| 132 | absent, and no fallback model is substituted for one. |
| 133 | |
| 134 | A roster that answers with IDs alone has said nothing about limits or |
| 135 | capabilities — it has not said they are unknown. Signed values therefore |
| 136 | **complete** a provider-live row where it is silent, and never displace what the |
| 137 | provider stated: layer 20 still wins every field it sets. Completion covers |
| 138 | context, max output and reasoning support. One helper does this for the picker, |
| 139 | the metadata lookup and the route resolver alike, so those three cannot drift; |
| 140 | on the route-scoped surfaces it is gated by the identity/endpoint rule below, |
| 141 | while the cross-provider merged view stays partition-scoped as it already is for |
| 142 | ordinary patches. It deliberately excludes price: a |
| 143 | filled price would sit on a provider-live row with a signed price source, which |
| 144 | the dispatch-quote check does not admit, so it would render without being |
| 145 | billable. Cloud prices continue to apply only where no fresh roster owns the |
| 146 | row, keeping the price classes atomic and the source recorded. |
| 147 | |
| 148 | Signed rows are scoped to one canonical provider identity on that provider's |
| 149 | official HTTPS endpoint contract, so a custom or proxied base URL never inherits |
| 150 | them. Catalog partitions collapse regional and dual-wire aliases onto a vendor |
| 151 | primary (`deepseek-cn` and `deepseek-anthropic` read `deepseek`; |
| 152 | `siliconflow-CN` reads `siliconflow`), and that collapse is not a channel for |
| 153 | facts: only a route whose own canonical identity is the identity the payload |
| 154 | names consumes them, matching how provider defaults have always been keyed. |
| 155 | Signing for an identity the catalog collapses is therefore inert rather than |
| 156 | cross-applied. The cross-provider merged view remains partition-scoped by |
| 157 | design; the endpoint contract is enforced at the route-scoped surfaces that |
| 158 | execution, pricing, and the model list read. |
| 159 | |
| 160 | Capability and price provenance are independent. A capability-only patch keeps |
| 161 | the original price source. A cloud price block replaces all token classes |
| 162 | atomically; omitted cache/input/output classes remain unknown. The source |
| 163 | records signed facts version, verifying key, fetch time and validity bound. |
| 164 | Mutable cloud prices are frozen with the exact dispatch route and persisted |
| 165 | with the existing cost receipt. Later refresh/disable cannot reprice that turn, |
| 166 | and an old receipt with no frozen cloud quote cannot borrow a later cloud price. |
| 167 | Provider-owned billing tiers, subscription/local surfaces and routing-dependent |
| 168 | prices retain their existing checks. |
| 169 | |
| 170 | Cloud model defaults are consulted only when no explicit selection or stronger |
| 171 | provider/account roster applies, through the normal route resolver. Codex model |
| 172 | availability and Ollama endpoint tags retain their own authority. Cloud data |
| 173 | cannot introduce a provider implementation, billing owner or wire protocol. |
| 174 | A cloud `base_url` field is accepted only by the shared static public HTTPS |
| 175 | endpoint contract; it is not consumed to migrate an execution endpoint. |
| 176 | |
| 177 | ## Website transport |
| 178 | |
| 179 | `web/app/api/facts/v1/[channel]/route.ts` implements GET/HEAD for the public |
| 180 | channel. It reads `facts_current` over PostgREST using only the publishable |
| 181 | Supabase key, validates the complete signed envelope and caches only verified |
| 182 | responses. Existing `CURATED_KV` can retain a last-good copy, which is bounded |
| 183 | and revalidated under the same current trust/time rules before stale fallback. |
| 184 | |
| 185 | With no active pinned key, delivery fails closed. Missing connection settings, |
| 186 | invalid upstream data or unavailable backing storage produce explicit errors. |
| 187 | HEAD responses, including errors, have no body. A strong ETag binds the complete |
| 188 | verified envelope, including signatures, so trust-material changes cannot reuse |
| 189 | an old representation validator. Conditional requests do not bypass validation. |
| 190 | |
| 191 | Required deployment configuration, if separately authorized, is `SUPABASE_URL` |
| 192 | and `SUPABASE_PUBLISHABLE_KEY`. A service-role credential never belongs in the |
| 193 | website. `facts_current` must be a read-only view with explicit SELECT grants, |
| 194 | RLS and policies limited to published public channels. This repository slice |
| 195 | performs no remote schema, grant, key, or data mutation; those controls require |
| 196 | separate deployment evidence. |
| 197 | |
| 198 | Two storage properties are part of the delivery contract rather than an |
| 199 | implementation detail, because a published fact is retracted through them: |
| 200 | |
| 201 | - **A channel serves its head version only.** Revoking, expiring or |
| 202 | future-dating the head must make the channel serve *nothing*, never the |
| 203 | previous release. Silently re-serving an older version is a rollback |
| 204 | delivered to every client whose version floor is not yet set; the repair for |
| 205 | a bad release is publishing a higher `facts_version`, and the client's own |
| 206 | rollback floor is the second line of defence, not the first. |
| 207 | - **`facts_version` is monotonic per channel.** Accepting a version at or below |
| 208 | a channel's published high-water mark would let a withdrawn payload return. |
| 209 | |
| 210 | Retraction therefore has two independent halves, and the operator should know |
| 211 | which one they are using. Publishing a later payload that drops the entry (or |
| 212 | letting `not_after` lapse) retracts the *fact*, and a client applies that at its |
| 213 | next successful refresh. `facts-publish.mjs revoke` stops the *release* at the |
| 214 | transport instead: it hands nothing to a client that asks, so a client already |
| 215 | holding the revoked envelope keeps applying it until its cached copy goes stale |
| 216 | — `ttl_secs`, 6 h by default, after which the payload stops being applied |
| 217 | whether or not a refresh succeeds. Neither half is instantaneous, and this layer |
| 218 | has no recall channel; a fact that must stop applying at an exact moment belongs |
| 219 | in `not_after`, not in a later revocation. |
| 220 | |
| 221 | ## Authoring and public fixtures |
| 222 | |
| 223 | `web/scripts/check-cloud-facts.mjs` checks the unsigned source, release receipt, |
| 224 | Rust/web key-table equality and the public signed fixtures. It distinguishes a |
| 225 | valid empty trust table from a parser failure and rejects fixture trust anchors. |
| 226 | |
| 227 | `web/scripts/facts-publish.mjs` supports validation, key generation, signing, |
| 228 | verification, SQL generation and publication. Signing/publishing are operator |
| 229 | actions requiring the relevant authority. Production verification/signing |
| 230 | requires an active pinned key; explicit fixture verification is separate. Key |
| 231 | generation creates a new private file exclusively, and CI signing is rejected |
| 232 | before any private-key read. Numeric version fields must be safe positive |
| 233 | integers before SQL or publication. Do not use real private keys in a repository, |
| 234 | logs or test fixtures. |
| 235 | |
| 236 | `docs/cloud-facts/fixtures/test-only-signing-key.pem` is deliberately public and |
| 237 | has one exact GitGuardian path exception. Its public key is never pinned in a |
| 238 | production table. Tests may sign synthetic payloads using that fixture or an |
| 239 | ephemeral in-memory test key. No fixture signature establishes production trust. |
| 240 | |
| 241 | To activate a future channel: approve key custody and its public anchor, update |
| 242 | both trust tables, verify and ship that anchor, then separately approve signing |
| 243 | and publication. Before signing, choose a facts version above the channel’s |
| 244 | verified published floor; the unsigned source version is not a live-channel receipt. Rotation pins the next key before dual-signing and retiring the |
| 245 | old key; there is no in-band command that can install or expand trust anchors. |
| 246 | |
| 247 | Release notices and announcements are represented and scoped but do not replace |
| 248 | the existing release checker or introduce announcement rendering in this slice. |
| 249 | Organization-specific trust, automated publication and endpoint migration are |
| 250 | not implemented. |
| 251 |