返回 CodeWhale
CLOUD_FACTS.md
根目录 / docs / CLOUD_FACTS.md
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 < 10 live Models.dev < 12 Codewhale corrections
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. `pricing_withheld` (a reason)
89 clears a row's price so it reads as unknown rather than as a misleading flat
90 rate; the bundled corrections in `crates/config/assets/catalog_corrections.json`
91 use the same field and patch code. Creating a row requires either its
92 context window or an `allow_unlisted` assertion (below); an attested ID-only row
93 is created with every limit, price and capability **unknown** rather than
94 inferred from a sibling model or a lower stale layer. Deprecation annotates;
95 hide only removes lower bundled/Models.dev rows. Cloud data cannot delete
96 provider-live, account, config or user rows.
97
98 **Which rows a patch reaches.** An upsert replaces fields on a row held at
99 layer 0 or 10 — the bundled Models.dev seed or a live Models.dev refresh,
100 including rows a bundled Codewhale correction patched — and is skipped with a receipt on anything at
101 layer 20 and above. That reach is the point of the layer split: most models a
102 user sees are described by Models.dev rather than by the provider, so a stale
103 context window or a changed rate on such a model is exactly what a signed
104 correction exists to fix, without a reinstall.
105
106 The distinction is what was *asked*, not what was fetched most recently. A
107 provider `/v1/models` answer is a fact about an endpoint the user
108 authenticated to, so it outranks a signed correction and is only ever
109 completed, never displaced. A Models.dev refresh is a public third-party
110 catalog that is merely fresher than the copy compiled into the binary, so it
111 is corrigible on the same terms as that copy. A refreshed row therefore carries
112 `CatalogSource::ModelsDevLive` and no endpoint fingerprint; only a provider
113 roster carries `CatalogSource::Live`.
114
115 A provider `/v1/models` roster is authoritative for the IDs it lists **and for
116 its own omissions**. This client keeps no history of past rosters, so it cannot
117 tell a never-listed preview from a model the provider retired, and it does not
118 guess: no local layer — bundled, Models.dev, or anything else — is evidence
119 about what a provider once served. Without an explicit assertion the roster
120 stands, and a signed patch can never put an omitted ID back.
121
122 `allow_unlisted` is that explicit assertion: a signed boolean on one model
123 patch, default false, meaning "this exact ID is available on this provider's
124 official endpoint even though the roster omits it". It is honored only on an
125 `upsert` and only in a payload that carries `not_after`, so the claim always
126 expires and has to be renewed by publishing rather than lived with. An older
127 client that predates the field deserializes it as false and simply keeps roster
128 dominance. The assertion grants nothing else: it does not bypass identity,
129 region, endpoint, account/OAuth entitlement, or user configuration precedence,
130 and it names one exact ID — no prefix, family or fallback.
131
132 `hide` and `deprecate` act on a row the local catalog holds. An attested row is
133 retracted by dropping its upsert from the next payload or letting `not_after`
134 lapse. A failed or rejected request is never treated as evidence a model is
135 absent, and no fallback model is substituted for one.
136
137 A roster that answers with IDs alone has said nothing about limits or
138 capabilities — it has not said they are unknown. Signed values therefore
139 **complete** a provider-live row where it is silent, and never displace what the
140 provider stated: layer 20 still wins every field it sets. Completion covers
141 context, max output and reasoning support. One helper does this for the picker,
142 the metadata lookup and the route resolver alike, so those three cannot drift;
143 on the route-scoped surfaces it is gated by the identity/endpoint rule below,
144 while the cross-provider merged view stays partition-scoped as it already is for
145 ordinary patches. It deliberately excludes price: a
146 filled price would sit on a provider-live row with a signed price source, which
147 the dispatch-quote check does not admit, so it would render without being
148 billable. Cloud prices continue to apply only where no fresh roster owns the
149 row, keeping the price classes atomic and the source recorded.
150
151 Signed rows are scoped to one canonical provider identity on that provider's
152 official HTTPS endpoint contract, so a custom or proxied base URL never inherits
153 them. Catalog partitions collapse regional and dual-wire aliases onto a vendor
154 primary (`deepseek-cn` and `deepseek-anthropic` read `deepseek`;
155 `siliconflow-CN` reads `siliconflow`), and that collapse is not a channel for
156 facts: only a route whose own canonical identity is the identity the payload
157 names consumes them, matching how provider defaults have always been keyed.
158 Signing for an identity the catalog collapses is therefore inert rather than
159 cross-applied. The cross-provider merged view remains partition-scoped by
160 design; the endpoint contract is enforced at the route-scoped surfaces that
161 execution, pricing, and the model list read.
162
163 Capability and price provenance are independent. A capability-only patch keeps
164 the original price source. A cloud price block replaces all token classes
165 atomically; omitted cache/input/output classes remain unknown. The source
166 records signed facts version, verifying key, fetch time and validity bound.
167 Mutable cloud prices are frozen with the exact dispatch route and persisted
168 with the existing cost receipt. Later refresh/disable cannot reprice that turn,
169 and an old receipt with no frozen cloud quote cannot borrow a later cloud price.
170 Provider-owned billing tiers, subscription/local surfaces and routing-dependent
171 prices retain their existing checks.
172
173 Cloud model defaults are consulted only when no explicit selection or stronger
174 provider/account roster applies, through the normal route resolver. Codex model
175 availability and Ollama endpoint tags retain their own authority. Cloud data
176 cannot introduce a provider implementation, billing owner or wire protocol.
177 A cloud `base_url` field is accepted only by the shared static public HTTPS
178 endpoint contract; it is not consumed to migrate an execution endpoint.
179
180 ## Website transport
181
182 `web/app/api/facts/v1/[channel]/route.ts` implements GET/HEAD for the public
183 channel. It reads `facts_current` over PostgREST using only the publishable
184 Supabase key, validates the complete signed envelope and caches only verified
185 responses. Existing `CURATED_KV` can retain a last-good copy, which is bounded
186 and revalidated under the same current trust/time rules before stale fallback.
187
188 With no active pinned key, delivery fails closed. Missing connection settings,
189 invalid upstream data or unavailable backing storage produce explicit errors.
190 HEAD responses, including errors, have no body. A strong ETag binds the complete
191 verified envelope, including signatures, so trust-material changes cannot reuse
192 an old representation validator. Conditional requests do not bypass validation.
193
194 Required deployment configuration, if separately authorized, is `SUPABASE_URL`
195 and `SUPABASE_PUBLISHABLE_KEY`. A service-role credential never belongs in the
196 website. `facts_current` must be a read-only view with explicit SELECT grants,
197 RLS and policies limited to published public channels. This repository slice
198 performs no remote schema, grant, key, or data mutation; those controls require
199 separate deployment evidence.
200
201 Two storage properties are part of the delivery contract rather than an
202 implementation detail, because a published fact is retracted through them:
203
204 - **A channel serves its head version only.** Revoking, expiring or
205 future-dating the head must make the channel serve *nothing*, never the
206 previous release. Silently re-serving an older version is a rollback
207 delivered to every client whose version floor is not yet set; the repair for
208 a bad release is publishing a higher `facts_version`, and the client's own
209 rollback floor is the second line of defence, not the first.
210 - **`facts_version` is monotonic per channel.** Accepting a version at or below
211 a channel's published high-water mark would let a withdrawn payload return.
212
213 Retraction therefore has two independent halves, and the operator should know
214 which one they are using. Publishing a later payload that drops the entry (or
215 letting `not_after` lapse) retracts the *fact*, and a client applies that at its
216 next successful refresh. `facts-publish.mjs revoke` stops the *release* at the
217 transport instead: it hands nothing to a client that asks, so a client already
218 holding the revoked envelope keeps applying it until its cached copy goes stale
219 — `ttl_secs`, 6 h by default, after which the payload stops being applied
220 whether or not a refresh succeeds. Neither half is instantaneous, and this layer
221 has no recall channel; a fact that must stop applying at an exact moment belongs
222 in `not_after`, not in a later revocation.
223
224 ## Authoring and public fixtures
225
226 `web/scripts/check-cloud-facts.mjs` checks the unsigned source, release receipt,
227 Rust/web key-table equality and the public signed fixtures. It distinguishes a
228 valid empty trust table from a parser failure and rejects fixture trust anchors.
229
230 `web/scripts/facts-publish.mjs` supports validation, key generation, signing,
231 verification, SQL generation and publication. Signing/publishing are operator
232 actions requiring the relevant authority. Production verification/signing
233 requires an active pinned key; explicit fixture verification is separate. Key
234 generation creates a new private file exclusively, and CI signing is rejected
235 before any private-key read. Numeric version fields must be safe positive
236 integers before SQL or publication. Do not use real private keys in a repository,
237 logs or test fixtures.
238
239 `docs/cloud-facts/fixtures/test-only-signing-key.pem` is deliberately public and
240 has one exact GitGuardian path exception. Its public key is never pinned in a
241 production table. Tests may sign synthetic payloads using that fixture or an
242 ephemeral in-memory test key. No fixture signature establishes production trust.
243
244 To activate a future channel: approve key custody and its public anchor, update
245 both trust tables, verify and ship that anchor, then separately approve signing
246 and publication. Before signing, choose a facts version above the channel’s
247 verified published floor; the unsigned source version is not a live-channel receipt. Rotation pins the next key before dual-signing and retiring the
248 old key; there is no in-band command that can install or expand trust anchors.
249
250 Release notices and announcements are represented and scoped but do not replace
251 the existing release checker or introduce announcement rendering in this slice.
252 Organization-specific trust, automated publication and endpoint migration are
253 not implemented.
254
254 lines MARKDOWN