Skip to main content

boatramp_types/
function.rs

1//! The FaaS **function** model — PLAN-faas FA-1.
2//!
3//! A **function** is the compute *artifact*: a versioned WASI component + its
4//! binding/capability config. It is the one primitive the engine runs (decision 1
5//! — "one primitive, two views"). A **handler** is *not* a resource — it is a
6//! function reached by an HTTP **route** trigger; likewise a *consumer* / *cron* is
7//! a function reached by a queue / timer trigger (decision 5). Triggers are their
8//! own objects, and many can point at one function version (decision 2).
9//!
10//! [`desugar`] lowers a site's deploy-scoped `handlers/consumers/crons/streams`
11//! into functions + triggers with **no behavioural change** — the mandatory
12//! non-breaking gate: a site's handlers must run identically before and after.
13//! It is a pure config→shape transform; the content hash / version id of each
14//! function is assigned later, at `sync`, when its component blob is uploaded.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::{ConsumerConfig, DeployConfig, HandlerConfig, HandlerLimits, Overlap};
21use crate::file::FileEntry;
22
23/// A function's owner — a **site** or a **project/tenant**. Drives the KV/blob/sql
24/// binding prefix and the inherited RBAC (a site-scoped function gains no privilege
25/// over its site).
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Owner {
29    /// Owned by a site: `fn/<site>/<name>`, binding prefix + RBAC of the site.
30    Site(String),
31    /// A top-level function owned by a project/tenant: `fn/<name>`.
32    Project(String),
33}
34
35impl std::fmt::Display for Owner {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Site(s) => write!(f, "site:{s}"),
39            Self::Project(p) => write!(f, "project:{p}"),
40        }
41    }
42}
43
44/// A function version's lifecycle (decision 3: `DeployPinned` is the default).
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Lifecycle {
48    /// Versions + rolls back atomically with the owning site's deploy.
49    #[default]
50    DeployPinned,
51    /// Its own version / alias / rollback, independent of any deploy.
52    Independent,
53}
54
55/// The execution substrate (decision 1: a per-function knob; `wasm` is the default
56/// and scales to zero by instantiation; the stronger-isolation substrates are the
57/// compute backends — see PLAN-compute-backends).
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Runtime {
61    #[default]
62    Wasm,
63    Microvm,
64    Container,
65}
66
67impl Runtime {
68    /// The snake_case wire term (matches the serde `rename_all`).
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::Wasm => "wasm",
72            Self::Microvm => "microvm",
73            Self::Container => "container",
74        }
75    }
76}
77
78impl std::fmt::Display for Runtime {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(self.as_str())
81    }
82}
83
84/// A function's binding/capability config — the `HandlerConfig` capability fields
85/// (imports, resource limits, static env) *minus its trigger* (route/methods, which
86/// become a [`Trigger`]), plus the [`Runtime`] knob. A `HandlerConfig` *is* a
87/// function's config, so the engine has one path.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct FunctionConfig {
91    /// Requested host capabilities (interface names).
92    #[serde(skip_serializing_if = "Vec::is_empty")]
93    pub imports: Vec<String>,
94    /// Optional resource limits (mem / timeout / fuel).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub limits: Option<HandlerLimits>,
97    /// Static, non-secret environment.
98    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
99    pub env: BTreeMap<String, String>,
100    /// Env-var name → secret reference (`ENV_VAR` → `HOST_ENV`), resolved
101    /// server-side from the serve env at instantiation; the value is a
102    /// reference, never stored in the manifest or the control-plane store
103    /// (mirrors a site handler's `[handlers].secrets`).
104    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
105    pub secrets: BTreeMap<String, String>,
106    /// Execution substrate (default `wasm`).
107    pub runtime: Runtime,
108    /// Usage quota (FA-4). Absent / all-`None` ⇒ unlimited.
109    #[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
110    pub quota: FunctionQuota,
111    /// Signed inbound-webhook ingress (FA-5). Absent ⇒ no webhook endpoint.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub webhook: Option<WebhookConfig>,
114    /// Function-to-function invoke allowlist (FI): the target names this function
115    /// may call through the `invoke` capability. Each entry may use `*` wildcards
116    /// (`*` = any sibling, `img-*` = a family, `resize` = one literal). Deny by
117    /// default: empty ⇒ the function cannot invoke anything, even if it imports
118    /// `invoke`. Only consulted when `imports` contains `invoke`.
119    #[serde(skip_serializing_if = "Vec::is_empty")]
120    pub invoke_targets: Vec<String>,
121    /// Declared `bus:` stats-topic templates for the read-only `messaging-stats` capability: each a
122    /// `bus:`-relative topic with an optional literal `{tenant}` placeholder the HOST fills with this
123    /// invocation's resolved tenant (e.g. `sync/{tenant}/import`). A guest's `messaging-stats` call for
124    /// a `bus:` topic must name one of these verbatim; the host substitutes the tenant, so the guest
125    /// can never read another tenant's bus stats. Deny by default: empty ⇒ no bus stats readable. Only
126    /// consulted when `imports` contains `messaging-stats`.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub stats_topics: Vec<String>,
129    /// Per-component name allowlist for the per-tenant sealed-secret capability
130    /// (`boatramp:handlers/tenant-secrets`, task #493): the secret names this function may address
131    /// for its resolved tenant. Least-privilege: **empty ⇒ deny-all**. A name not in the list is
132    /// `access-denied`; the resolved tenant is always host-supplied. Only consulted when `imports`
133    /// contains a `tenant-secrets:*` right.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub tenant_secret_names: Vec<String>,
136    /// Per-component container allowlist for the guest blob-upload mint capability
137    /// (`boatramp:handlers/blob-upload`, S3 external ingress): the blob containers this function may
138    /// mint an upload credential for. Least-privilege: **empty ⇒ deny-all**. A container not in the
139    /// list is `access-denied`; the project is always host-forced from the resolved invocation scope,
140    /// and the site is host-forced from [`blob_upload_site`](Self::blob_upload_site). Only consulted
141    /// when `imports` contains a `blob-upload:*` right.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub upload_containers: Vec<String>,
144    /// Per-component container allowlist for the **plain `wasi:blobstore`** capability (host-enforced
145    /// tenant confinement; same contract as [`crate::config::HandlerConfig::blobstore_containers`]):
146    /// the blob containers this function may open, `{tenant}`-templated against its host-resolved OWN
147    /// tenant. SEPARATE from [`upload_containers`](Self::upload_containers) — no inheritance/fallback.
148    /// Empty on a multi-tenant function ⇒ deny-all (fail-closed); empty single-tenant ⇒ permissive.
149    /// Only consulted when `imports` contains `wasi:blobstore`.
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub blobstore_containers: Vec<String>,
152    /// Per-component **outbound-TCP allowlist** for the gated `wasi:sockets` capability (same contract
153    /// as [`crate::config::HandlerConfig::egress_allowlist`]): the `host:port` / `ip:port` /
154    /// `CIDR:port` targets this function may `connect` to. **Empty ⇒ deny-all.** A standalone
155    /// top-level function opts into outbound TCP by naming targets here; the engine's
156    /// `socket_addr_check` then enforces it connect-only + SSRF-floored, and ONLY when the node
157    /// posture `allow_guest_tcp_egress` is on. Only consulted when `imports` contains `wasi:sockets`.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub egress_allowlist: Vec<String>,
160    /// The site a **standalone top-level function** mints blob-upload credentials for
161    /// (`boatramp:handlers/blob-upload`, S3 external ingress). A site handler mints for its own
162    /// host-routed site, but a standalone function has no single resolved site — so it names one here,
163    /// in its config (**host-forced, never guest-supplied**: the WIT surface still has no project/site
164    /// parameter). At bind time the host validates the named site actually exists in the function's
165    /// (host-forced) project; the credential then lands objects at
166    /// `hblob/{project-qualified-site}/{container}/{key}` — the exact prefix the guest `compat::blob`
167    /// read path serves, so nothing new is invented. Absent, or naming a site that does not exist in
168    /// the project ⇒ **fail-closed** (`no-resolved-site`, the binding is not attached). A guest can
169    /// never override it. Only consulted when `imports` contains a `blob-upload:*` right.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub blob_upload_site: Option<String>,
172    /// Bind the plain `wasi:blobstore` capability to a NAMED SITE's shared blob container namespace
173    /// (`hblob/{project-qualified-site}/{container}/`) instead of this function's private
174    /// `hblob/{project-qualified-fn}/…` one — the blob analog of binding a shared `sql` database by
175    /// name (#13). A bus-triggered **standalone** function (the only path whose `wasi:blobstore` is
176    /// fn-private; a `routing.consumers` consumer is already site-scoped) can then read/write the SAME
177    /// objects a same-site HTTP handler and a `compute bind-blob --site` producer use — e.g. a delivery
178    /// worker reading the body the MX edge wrote. **HOST-FORCED** to this function's project (the guest
179    /// never names a project/site); the named site must EXIST in the project AND grant `wasi:blobstore`
180    /// in its `[handlers].allow_imports` (the same least-privilege consent a site's own consumer passes)
181    /// — else the blob binding **fails closed** (not attached; a guest blob op is `access-denied`, never
182    /// silently served from the fn-private namespace). The `blobstore_containers` allowlist, `{tenant}`
183    /// confinement, and object-key screening all still apply unchanged on top of the site scope, so a
184    /// `blob_site` binding gets EXACTLY what a same-site handler gets, never more. Absent ⇒ the
185    /// function's private `fn/<name>` blob scope (no regression). Only consulted when `imports` contains
186    /// `wasi:blobstore`.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub blob_site: Option<String>,
189    /// In-site tenancy decision for this function's `sql`/`orm` access (Dimension 0). Absent ⇒
190    /// *undeclared* (refused under the `multi-tenant` posture, treated as `Disabled` — plain
191    /// queries — under single-tenant/dev). `Scoped` opts into host-injected row scoping. Parsed via
192    /// the [`crate::tenancy::de_opt_tenancy`] bridge so it reads from RON (`project.cfg`) as well as
193    /// the stored JSON.
194    #[serde(
195        default,
196        deserialize_with = "crate::tenancy::de_opt_tenancy",
197        skip_serializing_if = "Option::is_none"
198    )]
199    pub tenancy: Option<crate::tenancy::Tenancy>,
200    /// JWKS/issuer config verifying the app bearer for a [`crate::tenancy::TenantSource::Token`]
201    /// tenant source when this function is invoked over HTTP (the function analogue of a site's
202    /// `[handlers.graphql.data].claims_from_token`). Absent ⇒ the token source can't verify, so it
203    /// resolves no value (fail-closed); the domain/none sources don't need it.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub token_claims: Option<crate::config::HandlerGraphqlTokenClaims>,
206}
207
208/// The signature scheme a webhook is verified under (FA-5).
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum WebhookAlgorithm {
212    /// `HMAC-SHA256(body, secret)`, hex — the GitHub/Stripe-style scheme.
213    #[default]
214    HmacSha256,
215}
216
217/// Signed inbound-webhook config for a function (FA-5). The verifying secret is a
218/// **reference to a host env var** (never stored plaintext — mirrors site
219/// secrets); the endpoint verifies the request signature over the raw body,
220/// constant-time, **before** the guest runs.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct WebhookConfig {
224    /// The host env var holding the shared secret.
225    pub secret_env: String,
226    /// Signature scheme (default HMAC-SHA256).
227    #[serde(default)]
228    pub algorithm: WebhookAlgorithm,
229    /// The header carrying the hex signature (default `x-boatramp-signature`; a
230    /// leading `sha256=` is accepted and stripped).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub signature_header: Option<String>,
233    /// Max request body accepted before verifying/dispatching (default 1 MiB).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub max_body_bytes: Option<u64>,
236    /// If set, a **verified** webhook publishes its body onto the project bus at
237    /// this topic (a consumer subscribes with `bus:<topic>`) and returns `202`,
238    /// **without running any component** — the "external event → the fabric"
239    /// ingress, so a quick webhook needs no dedicated `wasi:http` function. Unset
240    /// ⇒ the verified webhook invokes the function as before.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub publish: Option<String>,
243}
244
245impl WebhookConfig {
246    /// The signature header name (defaulted).
247    pub fn header(&self) -> &str {
248        self.signature_header
249            .as_deref()
250            .unwrap_or("x-boatramp-signature")
251    }
252    /// The body cap (defaulted to 1 MiB).
253    pub fn body_cap(&self) -> u64 {
254        self.max_body_bytes.unwrap_or(1024 * 1024)
255    }
256}
257
258impl FunctionConfig {
259    fn from_handler(h: &HandlerConfig) -> Self {
260        Self {
261            imports: h.imports.clone(),
262            limits: h.limits.clone(),
263            env: h.env.clone(),
264            // Site handlers carry their secret refs on the site config
265            // (`[handlers].secrets`), resolved by the handler dispatch path — a
266            // desugared handler-function starts with none of its own.
267            secrets: BTreeMap::new(),
268            runtime: Runtime::default(),
269            quota: FunctionQuota::default(),
270            webhook: None,
271            invoke_targets: Vec::new(),
272            // Carry the handler's declared `messaging-stats` bus-topic templates onto the desugared
273            // function so the grant surface is identical whichever path serves it.
274            stats_topics: h.stats_topics.clone(),
275            // Carry the handler's tenant-secret name allowlist likewise (grant surface parity).
276            tenant_secret_names: h.tenant_secret_names.clone(),
277            // Carry the handler's blob-upload container allowlist likewise (grant surface parity).
278            upload_containers: h.upload_containers.clone(),
279            // Carry the handler's plain-`wasi:blobstore` container allowlist likewise (grant parity).
280            blobstore_containers: h.blobstore_containers.clone(),
281            // Carry the handler's outbound-TCP `egress_allowlist` likewise (gated `wasi:sockets` parity).
282            egress_allowlist: h.egress_allowlist.clone(),
283            // A desugared handler-function runs on the site-handler dispatch path, where the site is
284            // host-ROUTED (not config-declared) — so it has no `blob_upload_site` of its own. The
285            // `blob_upload_site` field is exclusively the STANDALONE top-level function's way to name
286            // the site it mints for.
287            blob_upload_site: None,
288            // Likewise `blob_site` (shared `wasi:blobstore` namespace) is a standalone top-level
289            // function's field — a desugared site handler is already site-scoped on the dispatch path.
290            blob_site: None,
291            // A desugared handler-function carries its own per-handler tenancy when declared;
292            // absent ⇒ inherit the site config (the dispatch path applies the site decision).
293            tenancy: h.tenancy.clone(),
294            token_claims: h.token_claims.clone(),
295        }
296    }
297    fn from_consumer(c: &ConsumerConfig) -> Self {
298        Self {
299            imports: c.imports.clone(),
300            // A consumer's own tenancy (typically `signed_context` on the async lane) + token
301            // claims are carried onto the desugared function it runs as.
302            tenancy: c.tenancy.clone(),
303            token_claims: c.token_claims.clone(),
304            // Carry the consumer's declared `messaging-stats` bus-topic templates.
305            stats_topics: c.stats_topics.clone(),
306            // Carry the consumer's tenant-secret name allowlist likewise.
307            tenant_secret_names: c.tenant_secret_names.clone(),
308            // Carry the consumer's blob-upload container allowlist likewise.
309            upload_containers: c.upload_containers.clone(),
310            // Carry the consumer's plain-`wasi:blobstore` container allowlist likewise.
311            blobstore_containers: c.blobstore_containers.clone(),
312            // Carry the consumer's outbound-TCP `egress_allowlist` likewise (gated `wasi:sockets`).
313            egress_allowlist: c.egress_allowlist.clone(),
314            ..Default::default()
315        }
316    }
317}
318
319/// A reference to a function (a `None` version = the function's active version).
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct FunctionRef {
323    /// The function name (site-scoped names are `<site>/<name>`).
324    pub name: String,
325    /// A specific version id, or `None` for the active version.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub version: Option<String>,
328}
329
330/// A **trigger** — a separate object (decision 2). Many triggers may point at the
331/// same function version (e.g. a route *and* a cron). A `target` of `None` is a
332/// host-native trigger (a stream fan-out has no component).
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct Trigger {
336    /// What fires the trigger.
337    pub kind: TriggerKind,
338    /// The function it invokes, or `None` for a host-native trigger.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub target: Option<FunctionRef>,
341}
342
343/// The event that fires a trigger. The role words (route/queue/cron/stream) are the
344/// familiar site-view names; each is just *a way to reach a function*.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(tag = "type", rename_all = "snake_case")]
347pub enum TriggerKind {
348    /// An HTTP route — the "handler" shape. `host` scopes it to a virtualhost.
349    Route {
350        #[serde(default, skip_serializing_if = "Option::is_none")]
351        host: Option<String>,
352        path: String,
353        #[serde(default, skip_serializing_if = "Vec::is_empty")]
354        methods: Vec<String>,
355    },
356    /// A stable invoke name — `/api/functions/<name>` (the FaaS verb, FA-3).
357    Invoke { name: String },
358    /// A message topic — the "consumer" shape. An empty `group` is the default
359    /// work-queue; a non-empty `group` is a durable fan-out subscriber (every
360    /// message, its own cursor) starting at `start` on first subscription.
361    Queue {
362        topic: String,
363        #[serde(default, skip_serializing_if = "String::is_empty")]
364        group: String,
365        #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
366        start: crate::config::StartPosition,
367    },
368    /// A cron schedule — the "cron" shape.
369    Cron {
370        schedule: String,
371        #[serde(default)]
372        overlap: Overlap,
373    },
374    /// An object-storage change under a prefix (FA-5).
375    Blob { prefix: String },
376    /// A signed inbound webhook (FA-5). `secret_env` names the host env var
377    /// holding the verifying secret (never the secret itself).
378    Webhook { path: String, secret_env: String },
379    /// Host-native SSE / WebSocket topic fan-out — the "stream" shape (no component,
380    /// so a stream trigger's `target` is `None`).
381    Stream {
382        topics: Vec<String>,
383        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
384        websocket: bool,
385        #[serde(default, skip_serializing_if = "Option::is_none")]
386        publish_topic: Option<String>,
387    },
388}
389
390impl std::fmt::Display for Trigger {
391    /// A short one-line label for the functions view (`route GET /x`, `queue t`, …).
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        match &self.kind {
394            TriggerKind::Route { path, methods, .. } => {
395                let m = if methods.is_empty() {
396                    "*".to_string()
397                } else {
398                    methods.join(",")
399                };
400                write!(f, "route {m} {path}")
401            }
402            TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
403            TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
404            TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
405            TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
406            TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
407            TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
408        }
409    }
410}
411
412/// A **stored trigger** bound to a top-level function — the durable form the
413/// server dispatches (FA-3 *scheduled* + FA-5 *event sources*). Its owning
414/// function is the key context, so there is no separate `target`. Keyed under
415/// [`keys::trigger`].
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct FunctionTrigger {
419    /// Unique id within the function (the key suffix).
420    pub id: String,
421    /// What fires it.
422    pub kind: TriggerKind,
423    /// Cron dedup: the minute-stamp (`hour*60 + minute` within the day, or a
424    /// monotonic per-fire stamp) this trigger last fired at — durable so a fire
425    /// isn't repeated across a restart or (in a cluster) a leader change.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub last_fired_minute: Option<i64>,
428}
429
430/// A stored, content-addressed function resource (the FA-1/FA-2 keyspace form).
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(deny_unknown_fields)]
433pub struct Function {
434    /// The function name (unique within its owner).
435    pub name: String,
436    /// Who owns it (drives binding prefix + RBAC).
437    pub owner: Owner,
438    /// Immutable versions, newest last.
439    pub versions: Vec<FunctionVersion>,
440    /// The active version's id.
441    pub active: String,
442    /// Named aliases → version id (staging/previews; mirrors deploy aliases).
443    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
444    pub aliases: BTreeMap<String, String>,
445    /// Binding/capability config.
446    pub config: FunctionConfig,
447}
448
449/// A [`Function`] operation named a version id the function has no record of
450/// (`rollback`/`set_alias`). A typed error so a caller can distinguish it (e.g.
451/// map it to a 404) instead of string-matching a message.
452#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
453#[error("no version {id:?} in function {function:?}")]
454pub struct UnknownVersion {
455    /// The function the operation was on.
456    pub function: String,
457    /// The version id that was not found.
458    pub id: String,
459}
460
461impl Function {
462    /// A new top-level function with a single active version (the component blob).
463    /// The version id **is** the component's content hash (content-addressed).
464    pub fn new(
465        name: impl Into<String>,
466        owner: Owner,
467        component_hash: impl Into<String>,
468        config: FunctionConfig,
469        lifecycle: Lifecycle,
470        created: u64,
471    ) -> Self {
472        let hash = component_hash.into();
473        Self {
474            name: name.into(),
475            owner,
476            versions: vec![FunctionVersion {
477                id: hash.clone(),
478                component: hash.clone(),
479                created,
480                lifecycle,
481            }],
482            active: hash,
483            aliases: BTreeMap::new(),
484            config,
485        }
486    }
487
488    /// Add a version for `component_hash` (id = the hash) and make it active. If a
489    /// version with that hash already exists it is just re-activated (idempotent).
490    /// Returns the (active) version id.
491    pub fn upsert_version(
492        &mut self,
493        component_hash: impl Into<String>,
494        lifecycle: Lifecycle,
495        created: u64,
496    ) -> String {
497        let hash = component_hash.into();
498        if !self.versions.iter().any(|v| v.id == hash) {
499            self.versions.push(FunctionVersion {
500                id: hash.clone(),
501                component: hash.clone(),
502                created,
503                lifecycle,
504            });
505        }
506        self.active = hash.clone();
507        hash
508    }
509
510    /// Append a version for `component_hash` WITHOUT activating it (deploy-resilience #1b async
511    /// deploy): the version is stored but `active` (the served pointer) is unchanged, so it cannot
512    /// serve until background validation promotes it with [`rollback`](Self::rollback). Idempotent
513    /// on the version id. Returns the version id.
514    pub fn add_version(
515        &mut self,
516        component_hash: impl Into<String>,
517        lifecycle: Lifecycle,
518        created: u64,
519    ) -> String {
520        let hash = component_hash.into();
521        if !self.versions.iter().any(|v| v.id == hash) {
522            self.versions.push(FunctionVersion {
523                id: hash.clone(),
524                component: hash.clone(),
525                created,
526                lifecycle,
527            });
528        }
529        hash
530    }
531
532    /// Point `active` at an existing version id. `Err` if the version is unknown.
533    pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
534        if self.versions.iter().any(|v| v.id == to) {
535            self.active = to.to_string();
536            Ok(())
537        } else {
538            Err(UnknownVersion {
539                function: self.name.clone(),
540                id: to.to_string(),
541            })
542        }
543    }
544
545    /// Set `label` → an existing version id. `Err` if the version is unknown.
546    pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
547        if self.versions.iter().any(|v| v.id == version) {
548            self.aliases.insert(label.to_string(), version.to_string());
549            Ok(())
550        } else {
551            Err(UnknownVersion {
552                function: self.name.clone(),
553                id: version.to_string(),
554            })
555        }
556    }
557
558    /// Resolve a **reference** — an alias label or a version id — to the component
559    /// blob hash that backs it, if known. An alias label is resolved first, then a
560    /// version id (so a label named like an id still wins as a label).
561    pub fn resolve(&self, reference: &str) -> Option<&str> {
562        let id = self
563            .aliases
564            .get(reference)
565            .map(String::as_str)
566            .unwrap_or(reference);
567        self.versions
568            .iter()
569            .find(|v| v.id == id)
570            .map(|v| v.component.as_str())
571    }
572}
573
574/// One immutable version of a function.
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct FunctionVersion {
578    /// Immutable content-hash id.
579    pub id: String,
580    /// The component blob hash.
581    pub component: String,
582    /// Unix creation time.
583    pub created: u64,
584    /// This version's lifecycle.
585    #[serde(default)]
586    pub lifecycle: Lifecycle,
587}
588
589/// The desugared shape of a function derived from a site's `DeployConfig`. The
590/// `component` is still a **path** within the deploy — its content hash / version
591/// id is assigned at `sync`, when the blob is uploaded (so this stays a pure
592/// config→shape transform, testable without a blob store).
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct FunctionSpec {
595    /// Site-scoped function name (see [`handler_name`] / [`consumer_name`]).
596    pub name: String,
597    /// The component path within the deploy.
598    pub component: String,
599    /// Binding/capability config.
600    pub config: FunctionConfig,
601    /// Version lifecycle (deploy-scoped functions are `DeployPinned`).
602    pub lifecycle: Lifecycle,
603}
604
605/// How an invocation is delivered (FA-3). `Sync` runs inline and returns the
606/// function's response; `Async` durably enqueues the call, returns `202` with an
607/// id, and a drain worker runs it later (retried, then dead-lettered).
608#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "snake_case")]
610pub enum InvokeMode {
611    /// Run inline; the caller blocks on the function's response.
612    #[default]
613    Sync,
614    /// Durably enqueue; the caller gets an id to poll.
615    Async,
616}
617
618/// The lifecycle of a durable (async) invocation.
619#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "snake_case")]
621pub enum InvocationStatus {
622    /// Enqueued, not yet claimed by a drain worker.
623    #[default]
624    Queued,
625    /// Claimed and executing.
626    Running,
627    /// Completed (the function returned a response — any HTTP status).
628    Succeeded,
629    /// Exhausted its attempts and was dead-lettered.
630    Failed,
631}
632
633/// The captured response of a completed invocation (sync idempotency replay +
634/// async poll both read this). The body is base64 so the record is plain JSON.
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(deny_unknown_fields)]
637pub struct InvocationResult {
638    /// The HTTP status the function returned.
639    pub status: u16,
640    /// The response content type, if any.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub content_type: Option<String>,
643    /// The response body, base64 (standard, no padding stripped).
644    pub body_b64: String,
645}
646
647/// A durable invocation record — the unit of the async queue and the receipt an
648/// idempotency key replays. Keyed under [`keys::invocation`].
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(deny_unknown_fields)]
651pub struct Invocation {
652    /// Opaque invocation id (also the queue key suffix).
653    pub id: String,
654    /// The function invoked.
655    pub function: String,
656    /// The function version that ran (or will run) — pinned at enqueue so a
657    /// later deploy can't silently change an in-flight async call.
658    pub version: String,
659    /// Delivery mode.
660    pub mode: InvokeMode,
661    /// Current status.
662    pub status: InvocationStatus,
663    /// The idempotency key that created it, if any.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub idempotency_key: Option<String>,
666    /// Delivery attempts so far (async).
667    #[serde(default)]
668    pub attempts: u32,
669    /// Lease expiry (unix seconds) while an async invocation is `Running`: the
670    /// drain that claims it stamps `now + ceiling + margin`, and the drain
671    /// treats a `Running` record whose lease has elapsed as reclaimable — so a
672    /// node that crashes mid-invocation never orphans the job. `None` once the
673    /// invocation settles (or while still `Queued`).
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub lease_expires: Option<u64>,
676    /// The request body the function receives, base64.
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub request_b64: Option<String>,
679    /// The request content type forwarded to the function.
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub request_content_type: Option<String>,
682    /// The captured result once complete.
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub result: Option<InvocationResult>,
685    /// Unix create time.
686    pub created: u64,
687    /// Unix last-update time.
688    pub updated: u64,
689}
690
691impl Invocation {
692    /// Whether the invocation reached a terminal state.
693    pub fn is_terminal(&self) -> bool {
694        matches!(
695            self.status,
696            InvocationStatus::Succeeded | InvocationStatus::Failed
697        )
698    }
699}
700
701/// Per-function usage quota (FA-4) — `require`-style knobs enforced host-side,
702/// **fail-closed** (over the limit ⇒ `429`). Accounting, not billing: this bounds
703/// abuse, it does not charge. All limits are per node today (a cluster-wide token
704/// bucket is future work); `None` on a field means that dimension is unlimited.
705#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
706#[serde(default, deny_unknown_fields)]
707pub struct FunctionQuota {
708    /// Max invocations admitted within [`window_secs`](Self::window_secs) (a
709    /// fixed window: the counter resets when the window rolls over).
710    #[serde(skip_serializing_if = "Option::is_none")]
711    pub max_invocations: Option<u64>,
712    /// The rate-limit window length in seconds (defaults to 60 when a
713    /// `max_invocations` cap is set).
714    #[serde(skip_serializing_if = "Option::is_none")]
715    pub window_secs: Option<u64>,
716    /// Max concurrent in-flight invocations for this function (per node).
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub max_concurrent: Option<u32>,
719}
720
721impl FunctionQuota {
722    /// Whether any dimension is set (an all-`None` quota is a no-op the caller can
723    /// skip entirely).
724    pub fn is_unset(&self) -> bool {
725        self.max_invocations.is_none() && self.max_concurrent.is_none()
726    }
727    /// The effective rate-limit window (defaults to 60s).
728    pub fn window(&self) -> u64 {
729        self.window_secs.unwrap_or(60)
730    }
731}
732
733/// One invocation's measured cost, folded into a [`Metering`] aggregate.
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub struct MeteringSample {
736    /// Whether the invocation succeeded (the guest produced a response).
737    pub success: bool,
738    /// Wall-clock duration in milliseconds (the server-side CPU-time proxy; true
739    /// fuel accounting needs the engine to surface post-completion cost).
740    pub duration_ms: u64,
741    /// Request bytes delivered to the function.
742    pub bytes_in: u64,
743    /// Response bytes the function produced.
744    pub bytes_out: u64,
745}
746
747/// Host-side usage aggregate for one function (FA-4), tenant-isolated under
748/// [`keys::metering`]. It also carries the fixed-window rate-limit counter so
749/// metering + quota share a single read-modify-write.
750#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(default, deny_unknown_fields)]
752pub struct Metering {
753    /// The function these counters belong to.
754    pub function: String,
755    /// Total invocations metered (sync + drained async).
756    pub invocations: u64,
757    /// Invocations whose guest produced a response.
758    pub successes: u64,
759    /// Invocations that failed to deliver (engine error / dead-lettered).
760    pub failures: u64,
761    /// Summed wall-clock duration, milliseconds.
762    pub duration_ms_total: u64,
763    /// Summed request bytes.
764    pub bytes_in_total: u64,
765    /// Summed response bytes.
766    pub bytes_out_total: u64,
767    /// The current rate-limit window's start (unix seconds).
768    pub window_start: u64,
769    /// Invocations admitted in the current window.
770    pub window_count: u64,
771    /// Last update (unix seconds).
772    pub updated: u64,
773}
774
775impl Metering {
776    /// A fresh aggregate for `function`.
777    pub fn new(function: impl Into<String>) -> Self {
778        Self {
779            function: function.into(),
780            ..Default::default()
781        }
782    }
783
784    /// Fold one invocation's cost into the usage counters.
785    pub fn record(&mut self, sample: &MeteringSample, now: u64) {
786        self.invocations += 1;
787        if sample.success {
788            self.successes += 1;
789        } else {
790            self.failures += 1;
791        }
792        self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
793        self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
794        self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
795        self.updated = now;
796    }
797
798    /// Admit one invocation against the fixed-window rate limit, rolling the
799    /// window over when it has elapsed. Returns `true` if admitted (and records
800    /// the admission), `false` if the window is already at `max` (⇒ the caller
801    /// fails closed with `429`). An unset cap always admits.
802    pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
803        let Some(max) = quota.max_invocations else {
804            return true;
805        };
806        let window = quota.window();
807        if now.saturating_sub(self.window_start) >= window {
808            self.window_start = now;
809            self.window_count = 0;
810        }
811        if self.window_count >= max {
812            return false;
813        }
814        self.window_count += 1;
815        self.updated = now;
816        true
817    }
818}
819
820pub mod keys {
821    //! KV keyspace for a function (mirrors the deploy/alias immutability model), all
822    //! **project-scoped** under `project/<proj>/…` (0.2.0). `project` is passed as a
823    //! bare `&str` (this crate is wasm-clean and has no `ProjectRef`); callers in
824    //! `boatramp-core` pass `ProjectRef::as_str()`.
825
826    /// Function metadata.
827    pub fn meta(project: &str, name: &str) -> String {
828        format!("project/{project}/functions/{name}")
829    }
830    /// An immutable version.
831    pub fn version(project: &str, name: &str, id: &str) -> String {
832        format!("project/{project}/functions/{name}/versions/{id}")
833    }
834    /// A named alias → version id.
835    pub fn alias(project: &str, name: &str, label: &str) -> String {
836        format!("project/{project}/functions/{name}/alias/{label}")
837    }
838    /// A trigger bound to the function.
839    pub fn trigger(project: &str, name: &str, id: &str) -> String {
840        format!("project/{project}/functions/{name}/triggers/{id}")
841    }
842    /// A durable invocation record.
843    pub fn invocation(project: &str, name: &str, id: &str) -> String {
844        format!("project/{project}/functions/{name}/invocations/{id}")
845    }
846    /// The prefix under which all of a function's invocations live (queue scan).
847    pub fn invocations_prefix(project: &str, name: &str) -> String {
848        format!("project/{project}/functions/{name}/invocations/")
849    }
850    /// An idempotency key → invocation id pointer.
851    pub fn idempotency(project: &str, name: &str, key: &str) -> String {
852        format!("project/{project}/functions/{name}/idem/{key}")
853    }
854    /// The function's usage-metering aggregate (FA-4).
855    pub fn metering(project: &str, name: &str) -> String {
856        format!("project/{project}/metering/{name}")
857    }
858
859    /// The prefix listing every function record in a project (`meta` keys). A
860    /// function record's key has no further `/` after its name, so a lister filters
861    /// on that to skip the per-function sub-records.
862    pub fn functions_prefix(project: &str) -> String {
863        format!("project/{project}/functions/")
864    }
865    /// The prefix listing a function's triggers.
866    pub fn triggers_prefix(project: &str, name: &str) -> String {
867        format!("project/{project}/functions/{name}/triggers/")
868    }
869    /// The prefix listing every metering aggregate in a project.
870    pub fn metering_prefix(project: &str) -> String {
871        format!("project/{project}/metering/")
872    }
873}
874
875/// The site-scoped function name for an HTTP route handler — a slug of the route
876/// (`/api/hello` → `api-hello`, `/` → `root`).
877pub fn handler_name(route: &str) -> String {
878    let s = slug(route);
879    if s.is_empty() { "root".to_string() } else { s }
880}
881
882/// The site-scoped function name for a topic consumer (`orders` → `consumer-orders`).
883pub fn consumer_name(topic: &str) -> String {
884    format!("consumer-{}", slug(topic))
885}
886
887/// Lower-case alphanumeric slug, non-alnum runs collapsed to single `-`, trimmed.
888fn slug(s: &str) -> String {
889    let mut out = String::new();
890    let mut dash = false;
891    for c in s.chars() {
892        if c.is_ascii_alphanumeric() {
893            out.push(c.to_ascii_lowercase());
894            dash = false;
895        } else if !out.is_empty() && !dash {
896            out.push('-');
897            dash = true;
898        }
899    }
900    out.trim_matches('-').to_string()
901}
902
903/// Lower a site's deploy-scoped compute config into **functions + triggers**
904/// (decision 2), preserving behaviour exactly (the non-breaking gate):
905///
906/// - each `handler` → a `DeployPinned` [`FunctionSpec`] + a `Route` [`Trigger`];
907/// - each `consumer` → a [`FunctionSpec`] + a `Queue` trigger;
908/// - each `cron` → a `Cron` trigger onto the **function serving its route** (a
909///   second trigger on one function — N triggers → one function);
910/// - each `stream` → a host-native `Stream` trigger (`target: None`, no component).
911///
912/// Pure: no I/O, no hashing. The site handler surface (`routing.handlers`) is
913/// unchanged — this is the internal lowering that FA-1..FA-3 build on.
914pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
915    let mut functions = Vec::new();
916    let mut triggers = Vec::new();
917
918    for h in &cfg.handlers {
919        let name = handler_name(&h.route);
920        functions.push(FunctionSpec {
921            name: name.clone(),
922            component: h.component.clone(),
923            config: FunctionConfig::from_handler(h),
924            lifecycle: Lifecycle::DeployPinned,
925        });
926        triggers.push(Trigger {
927            kind: TriggerKind::Route {
928                host: None,
929                path: h.route.clone(),
930                methods: h.methods.clone(),
931            },
932            target: Some(FunctionRef {
933                name,
934                version: None,
935            }),
936        });
937    }
938
939    for c in &cfg.consumers {
940        let name = consumer_name(&c.topic);
941        functions.push(FunctionSpec {
942            name: name.clone(),
943            component: c.component.clone(),
944            config: FunctionConfig::from_consumer(c),
945            lifecycle: Lifecycle::DeployPinned,
946        });
947        triggers.push(Trigger {
948            kind: TriggerKind::Queue {
949                topic: c.topic.clone(),
950                group: c.group.clone(),
951                start: c.start,
952            },
953            target: Some(FunctionRef {
954                name,
955                version: None,
956            }),
957        });
958    }
959
960    for cr in &cfg.crons {
961        // A cron fires an existing handler-function, addressed by its route.
962        let target = cfg
963            .handlers
964            .iter()
965            .find(|h| h.route == cr.route)
966            .map(|h| FunctionRef {
967                name: handler_name(&h.route),
968                version: None,
969            });
970        triggers.push(Trigger {
971            kind: TriggerKind::Cron {
972                schedule: cr.schedule.clone(),
973                overlap: cr.overlap,
974            },
975            target,
976        });
977    }
978
979    for s in &cfg.streams {
980        triggers.push(Trigger {
981            kind: TriggerKind::Stream {
982                topics: s.topics.clone(),
983                websocket: s.websocket,
984                publish_topic: s.publish_topic.clone(),
985            },
986            target: None,
987        });
988    }
989
990    (functions, triggers)
991}
992
993/// Materialize desugared specs into stored [`Function`]s for a site, resolving each
994/// component **path** to its blob hash via the deploy's file map (the blob hash is
995/// the content-addressed version id). `created` is the deploy's activation time.
996/// Specs whose component isn't in the file map are dropped (a validated deploy
997/// always has them). This is the derived, read-only view of a site's functions
998/// (FA-1); independently-stored top-level functions come with FA-2.
999pub fn materialize(
1000    specs: &[FunctionSpec],
1001    site: &str,
1002    files: &BTreeMap<String, FileEntry>,
1003    created: u64,
1004) -> Vec<Function> {
1005    specs
1006        .iter()
1007        .filter_map(|s| {
1008            let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
1009            Some(Function {
1010                name: s.name.clone(),
1011                owner: Owner::Site(site.to_string()),
1012                versions: vec![FunctionVersion {
1013                    id: hash.clone(),
1014                    component: hash.clone(),
1015                    created,
1016                    lifecycle: s.lifecycle,
1017                }],
1018                active: hash,
1019                aliases: BTreeMap::new(),
1020                config: s.config.clone(),
1021            })
1022        })
1023        .collect()
1024}
1025
1026/// A read-only summary of a route/function's declared in-site tenancy, surfaced on
1027/// [`FunctionSummary`] so an operator can confirm the scope + the `on_unresolved` pass-vs-deny
1028/// decision WITHOUT black-box probing (the gap construens hit diagnosing an applied-but-seemingly-
1029/// ignored `on_unresolved: "pass"`). `None` on [`FunctionSummary::tenancy`] ⇒ the function declares
1030/// no tenancy of its own and inherits the site decision (read the site config to see that one).
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032pub struct TenancySummary {
1033    /// The tenancy class: `"scoped"` | `"target"` | `"disabled"`.
1034    pub mode: String,
1035    /// The tenant column (`scoped` only).
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub column: Option<String>,
1038    /// Read access mode (`scoped`): `none` | `null` | `own` | `own_or_null` | `all`.
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub read: Option<String>,
1041    /// Write access mode (`scoped`).
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub write: Option<String>,
1044    /// The unresolved-request policy (`scoped`): `"deny"` (default) | `"pass"`.
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub on_unresolved: Option<String>,
1047}
1048
1049impl TenancySummary {
1050    /// Summarize a declared [`Tenancy`](crate::tenancy::Tenancy) for the read-only function view.
1051    pub fn of(t: &crate::tenancy::Tenancy) -> Self {
1052        use crate::tenancy::{AccessMode, OnUnresolved, Tenancy};
1053        let access = |m: &AccessMode| {
1054            match m {
1055                AccessMode::None => "none",
1056                AccessMode::Null => "null",
1057                AccessMode::Own => "own",
1058                AccessMode::OwnOrNull => "own_or_null",
1059                AccessMode::All => "all",
1060            }
1061            .to_string()
1062        };
1063        match t {
1064            Tenancy::Disabled => Self {
1065                mode: "disabled".to_string(),
1066                column: None,
1067                read: None,
1068                write: None,
1069                on_unresolved: None,
1070            },
1071            Tenancy::Scoped {
1072                column,
1073                read,
1074                write,
1075                on_unresolved,
1076                ..
1077            } => Self {
1078                mode: "scoped".to_string(),
1079                column: Some(column.clone()),
1080                read: Some(access(read)),
1081                write: Some(access(write)),
1082                on_unresolved: Some(
1083                    match on_unresolved {
1084                        OnUnresolved::Pass => "pass",
1085                        OnUnresolved::Deny => "deny",
1086                    }
1087                    .to_string(),
1088                ),
1089            },
1090            Tenancy::Target { .. } => Self {
1091                mode: "target".to_string(),
1092                column: None,
1093                read: Some("target".to_string()),
1094                write: None,
1095                on_unresolved: None,
1096            },
1097        }
1098    }
1099}
1100
1101/// One entry in the `GET /api/functions` view: a function plus its resolved
1102/// active version and the triggers that reach it. The read-only projection the
1103/// server returns and the CLI/console render.
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1105pub struct FunctionSummary {
1106    /// Function name (`<site>/<name>` for site-scoped; bare for top-level).
1107    pub name: String,
1108    /// Owner (`site:<site>` or `project:<project>`).
1109    pub owner: String,
1110    /// Execution substrate.
1111    pub runtime: String,
1112    /// Active version id (the component blob hash).
1113    pub version: String,
1114    /// Rendered triggers that reach this function.
1115    pub triggers: Vec<String>,
1116    /// The function's declared in-site tenancy (scope + `on_unresolved`), so an operator can confirm
1117    /// pass-vs-deny without probing. `None` ⇒ no own tenancy (inherits the site). Elided when absent
1118    /// so a pre-existing consumer (and the console's field-subset mirror) is unaffected.
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub tenancy: Option<TenancySummary>,
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
1127
1128    fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
1129        HandlerConfig {
1130            tenancy: None,
1131            token_claims: None,
1132            no_secrets: false,
1133            route: route.into(),
1134            methods: methods
1135                .iter()
1136                .map(std::string::ToString::to_string)
1137                .collect(),
1138            component: component.into(),
1139            imports: imports
1140                .iter()
1141                .map(std::string::ToString::to_string)
1142                .collect(),
1143            streaming: false,
1144            limits: None,
1145            env: BTreeMap::new(),
1146            secrets: Vec::new(),
1147            invoke_targets: Vec::new(),
1148            stats_topics: Vec::new(),
1149            tenant_secret_names: Vec::new(),
1150            upload_containers: Vec::new(),
1151            blobstore_containers: Vec::new(),
1152            egress_allowlist: Vec::new(),
1153        }
1154    }
1155
1156    #[test]
1157    fn slugs_and_names() {
1158        assert_eq!(handler_name("/api/hello"), "api-hello");
1159        assert_eq!(handler_name("/"), "root");
1160        assert_eq!(handler_name("/a/b/*"), "a-b");
1161        assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
1162    }
1163
1164    /// The mandatory **non-breaking gate**: desugaring preserves every handler,
1165    /// consumer, cron, and stream's fields exactly — the same component, imports,
1166    /// routes/methods, topics, and the cron→handler binding.
1167    #[test]
1168    fn desugar_preserves_all_compute_config() {
1169        let cfg = DeployConfig {
1170            handlers: vec![
1171                handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
1172                handler("/api/report", "report.wasm", &[], &[]),
1173            ],
1174            consumers: vec![ConsumerConfig {
1175                max_concurrency: None,
1176                tenancy: None,
1177                token_claims: None,
1178                topic: "orders".into(),
1179                component: "orders.wasm".into(),
1180                imports: vec!["sql".into()],
1181                group: String::new(),
1182                start: Default::default(),
1183                secrets: Vec::new(),
1184                lease_ms: None,
1185                max_attempts: None,
1186                max_batch: None,
1187                max_ack_pending: None,
1188                backoff_ms: None,
1189                retention_ms: None,
1190                stats_topics: Vec::new(),
1191                tenant_secret_names: Vec::new(),
1192                upload_containers: Vec::new(),
1193                blobstore_containers: Vec::new(),
1194                egress_allowlist: Vec::new(),
1195            }],
1196            crons: vec![CronConfig {
1197                schedule: "0 * * * *".into(),
1198                route: "/api/report".into(),
1199                overlap: Overlap::Skip,
1200                run_as: crate::config::CronRunAs::Unauthenticated,
1201            }],
1202            streams: vec![StreamConfig {
1203                route: "/live".into(),
1204                topics: vec!["ticks".into()],
1205                websocket: false,
1206                publish_topic: None,
1207                authz: None,
1208            }],
1209            ..Default::default()
1210        };
1211
1212        let (functions, triggers) = desugar(&cfg);
1213
1214        // Two handlers + one consumer → three functions.
1215        assert_eq!(functions.len(), 3);
1216        let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
1217        assert_eq!(hello.component, "hello.wasm");
1218        assert_eq!(hello.config.imports, vec!["kv".to_string()]);
1219        assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
1220        assert_eq!(hello.config.runtime, Runtime::Wasm);
1221        let consumer = functions
1222            .iter()
1223            .find(|f| f.name == "consumer-orders")
1224            .unwrap();
1225        assert_eq!(consumer.component, "orders.wasm");
1226        assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
1227
1228        // Triggers: 2 routes + 1 queue + 1 cron + 1 stream = 5.
1229        assert_eq!(triggers.len(), 5);
1230
1231        // The route trigger carries the exact path + methods and targets its function.
1232        let route = triggers
1233            .iter()
1234            .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
1235            .unwrap();
1236        match &route.kind {
1237            TriggerKind::Route { methods, host, .. } => {
1238                assert_eq!(methods, &["GET".to_string()]);
1239                assert!(host.is_none());
1240            }
1241            _ => unreachable!(),
1242        }
1243        assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
1244
1245        // The queue trigger.
1246        let queue = triggers
1247            .iter()
1248            .find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
1249            .unwrap();
1250        assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
1251
1252        // The cron is a SECOND trigger on the /api/report function (N triggers → 1 fn).
1253        let cron = triggers
1254            .iter()
1255            .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
1256            .unwrap();
1257        assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
1258
1259        // The stream is host-native: a trigger with no function target.
1260        let stream = triggers
1261            .iter()
1262            .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
1263            .unwrap();
1264        assert!(stream.target.is_none());
1265        match &stream.kind {
1266            TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
1267            _ => unreachable!(),
1268        }
1269    }
1270
1271    #[test]
1272    fn materialize_resolves_paths_to_blob_hashes() {
1273        let cfg = DeployConfig {
1274            handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
1275            ..Default::default()
1276        };
1277        let (specs, _) = desugar(&cfg);
1278        let files = BTreeMap::from([(
1279            "hello.wasm".to_string(),
1280            FileEntry {
1281                hash: "sha256:abc".into(),
1282                size: 10,
1283                content_type: None,
1284                variants: BTreeMap::new(),
1285            },
1286        )]);
1287        let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1288        assert_eq!(funcs.len(), 1);
1289        assert_eq!(funcs[0].name, "api-hello");
1290        assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1291        assert_eq!(funcs[0].active, "sha256:abc");
1292        assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1293        assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1294        // A spec whose component blob is absent is dropped (no phantom function).
1295        assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1296    }
1297
1298    #[test]
1299    fn empty_config_desugars_to_nothing() {
1300        let (functions, triggers) = desugar(&DeployConfig::default());
1301        assert!(functions.is_empty() && triggers.is_empty());
1302    }
1303
1304    #[test]
1305    fn model_serde_round_trips() {
1306        let f = Function {
1307            name: "resize".into(),
1308            owner: Owner::Project("acme".into()),
1309            versions: vec![FunctionVersion {
1310                id: "v1abc".into(),
1311                component: "blob:deadbeef".into(),
1312                created: 1_800_000_000,
1313                lifecycle: Lifecycle::Independent,
1314            }],
1315            active: "v1abc".into(),
1316            aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1317            config: FunctionConfig {
1318                imports: vec!["blobstore".into()],
1319                runtime: Runtime::Microvm,
1320                ..Default::default()
1321            },
1322        };
1323        let json = serde_json::to_string(&f).unwrap();
1324        assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1325
1326        // A trigger with a data-carrying kind + host-native (no target) both round-trip.
1327        for t in [
1328            Trigger {
1329                kind: TriggerKind::Route {
1330                    host: Some("example.com".into()),
1331                    path: "/x".into(),
1332                    methods: vec!["POST".into()],
1333                },
1334                target: Some(FunctionRef {
1335                    name: "resize".into(),
1336                    version: None,
1337                }),
1338            },
1339            Trigger {
1340                kind: TriggerKind::Stream {
1341                    topics: vec!["t".into()],
1342                    websocket: true,
1343                    publish_topic: Some("up".into()),
1344                },
1345                target: None,
1346            },
1347        ] {
1348            let j = serde_json::to_string(&t).unwrap();
1349            assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1350        }
1351    }
1352
1353    #[test]
1354    fn versioning_alias_and_rollback() {
1355        let mut f = Function::new(
1356            "resize",
1357            Owner::Project("acme".into()),
1358            "hashA",
1359            FunctionConfig::default(),
1360            Lifecycle::Independent,
1361            1,
1362        );
1363        assert_eq!(f.active, "hashA");
1364        assert_eq!(f.versions.len(), 1);
1365
1366        // A new component → a new active version.
1367        f.upsert_version("hashB", Lifecycle::Independent, 2);
1368        assert_eq!(f.active, "hashB");
1369        assert_eq!(f.versions.len(), 2);
1370        // Re-deploying the same hash is idempotent (re-activates, no dup version).
1371        f.upsert_version("hashA", Lifecycle::Independent, 3);
1372        assert_eq!(f.active, "hashA");
1373        assert_eq!(f.versions.len(), 2);
1374
1375        // Alias to a known version; unknown is rejected.
1376        f.set_alias("prod", "hashB").unwrap();
1377        assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1378        assert!(f.set_alias("prod", "ghost").is_err());
1379
1380        // Rollback to a known version; unknown is rejected.
1381        f.rollback("hashB").unwrap();
1382        assert_eq!(f.active, "hashB");
1383        assert!(f.rollback("ghost").is_err());
1384
1385        // `resolve` maps a version id OR an alias label to the component hash;
1386        // an unknown reference resolves to nothing.
1387        assert_eq!(f.resolve("hashA"), Some("hashA"));
1388        assert_eq!(f.resolve("prod"), Some("hashB")); // alias → version → component
1389        assert_eq!(f.resolve("ghost"), None);
1390    }
1391
1392    #[test]
1393    fn invocation_model_round_trips_and_reports_terminal() {
1394        let inv = Invocation {
1395            id: "inv-1".into(),
1396            function: "greeter".into(),
1397            version: "hashA".into(),
1398            mode: InvokeMode::Async,
1399            status: InvocationStatus::Queued,
1400            idempotency_key: Some("k".into()),
1401            attempts: 0,
1402            lease_expires: None,
1403            request_b64: Some("aGk=".into()),
1404            request_content_type: Some("text/plain".into()),
1405            result: None,
1406            created: 1,
1407            updated: 1,
1408        };
1409        assert!(!inv.is_terminal());
1410        let json = serde_json::to_string(&inv).unwrap();
1411        let back: Invocation = serde_json::from_str(&json).unwrap();
1412        assert_eq!(back, inv);
1413        // Enum wire forms are snake_case + stable.
1414        assert!(json.contains("\"mode\":\"async\""));
1415        assert!(json.contains("\"status\":\"queued\""));
1416
1417        let done = Invocation {
1418            status: InvocationStatus::Succeeded,
1419            result: Some(InvocationResult {
1420                status: 200,
1421                content_type: None,
1422                body_b64: "b2s=".into(),
1423            }),
1424            ..inv
1425        };
1426        assert!(done.is_terminal());
1427    }
1428
1429    #[test]
1430    fn metering_records_and_rate_limits() {
1431        let mut m = Metering::new("greeter");
1432        m.record(
1433            &MeteringSample {
1434                success: true,
1435                duration_ms: 5,
1436                bytes_in: 3,
1437                bytes_out: 7,
1438            },
1439            100,
1440        );
1441        m.record(
1442            &MeteringSample {
1443                success: false,
1444                duration_ms: 2,
1445                bytes_in: 0,
1446                bytes_out: 0,
1447            },
1448            101,
1449        );
1450        assert_eq!(m.invocations, 2);
1451        assert_eq!(m.successes, 1);
1452        assert_eq!(m.failures, 1);
1453        assert_eq!(m.duration_ms_total, 7);
1454        assert_eq!(m.bytes_out_total, 7);
1455        assert_eq!(m.updated, 101);
1456
1457        // Fixed-window rate limit: 2 per 10s window.
1458        let quota = FunctionQuota {
1459            max_invocations: Some(2),
1460            window_secs: Some(10),
1461            max_concurrent: None,
1462        };
1463        let mut r = Metering::new("greeter");
1464        assert!(r.admit(&quota, 1000)); // 1st in window
1465        assert!(r.admit(&quota, 1001)); // 2nd
1466        assert!(!r.admit(&quota, 1002)); // 3rd → rejected
1467        assert_eq!(r.window_count, 2);
1468        // The window rolls over after `window_secs`, resetting the counter.
1469        assert!(r.admit(&quota, 1011));
1470        assert_eq!(r.window_count, 1);
1471
1472        // An unset cap always admits and never touches the counter.
1473        let unset = FunctionQuota::default();
1474        let mut u = Metering::new("greeter");
1475        assert!(u.admit(&unset, 1));
1476        assert_eq!(u.window_count, 0);
1477        assert!(unset.is_unset());
1478    }
1479
1480    #[test]
1481    fn webhook_config_defaults_and_round_trips() {
1482        // Defaults: header + body cap.
1483        let w = WebhookConfig {
1484            secret_env: "HOOK_SECRET".into(),
1485            algorithm: WebhookAlgorithm::HmacSha256,
1486            signature_header: None,
1487            max_body_bytes: None,
1488            publish: None,
1489        };
1490        assert_eq!(w.header(), "x-boatramp-signature");
1491        assert_eq!(w.body_cap(), 1024 * 1024);
1492
1493        // A config carrying a webhook round-trips and the secret is an env *ref*.
1494        let cfg = FunctionConfig {
1495            webhook: Some(w),
1496            ..Default::default()
1497        };
1498        let json = serde_json::to_string(&cfg).unwrap();
1499        assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1500        assert!(json.contains("\"hmac_sha256\""));
1501        let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1502        assert_eq!(back, cfg);
1503
1504        // Custom header + cap are honoured.
1505        let custom = WebhookConfig {
1506            secret_env: "S".into(),
1507            algorithm: WebhookAlgorithm::HmacSha256,
1508            signature_header: Some("x-hub-signature-256".into()),
1509            max_body_bytes: Some(4096),
1510            publish: None,
1511        };
1512        assert_eq!(custom.header(), "x-hub-signature-256");
1513        assert_eq!(custom.body_cap(), 4096);
1514    }
1515
1516    #[test]
1517    fn secret_refs_round_trip_as_references() {
1518        // A `secrets` map is a name→host-env-var *reference*: it round-trips in the
1519        // config and carries no literal secret value (the value is resolved from the
1520        // serve env at instantiation, never baked into the stored config/manifest).
1521        let cfg = FunctionConfig {
1522            env: BTreeMap::from([("STAGE".into(), "prod".into())]),
1523            secrets: BTreeMap::from([("DB_URL".into(), "PROD_DB_URL".into())]),
1524            ..Default::default()
1525        };
1526        let json = serde_json::to_string(&cfg).unwrap();
1527        // The map serializes the *reference* (the host env var name), not a value.
1528        assert!(json.contains("\"secrets\":{\"DB_URL\":\"PROD_DB_URL\"}"));
1529        let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1530        assert_eq!(back, cfg);
1531        assert_eq!(
1532            back.secrets.get("DB_URL").map(String::as_str),
1533            Some("PROD_DB_URL")
1534        );
1535
1536        // An empty `secrets` map is elided (same serde shape as the handler's).
1537        let bare = FunctionConfig::default();
1538        assert!(!serde_json::to_string(&bare).unwrap().contains("secrets"));
1539    }
1540
1541    #[test]
1542    fn keyspace_is_stable() {
1543        assert_eq!(
1544            keys::meta("default", "resize"),
1545            "project/default/functions/resize"
1546        );
1547        assert_eq!(
1548            keys::version("default", "resize", "v1"),
1549            "project/default/functions/resize/versions/v1"
1550        );
1551        assert_eq!(
1552            keys::alias("default", "resize", "prod"),
1553            "project/default/functions/resize/alias/prod"
1554        );
1555        assert_eq!(
1556            keys::trigger("default", "resize", "t1"),
1557            "project/default/functions/resize/triggers/t1"
1558        );
1559        assert_eq!(
1560            keys::invocation("default", "resize", "inv-1"),
1561            "project/default/functions/resize/invocations/inv-1"
1562        );
1563        assert_eq!(
1564            keys::invocations_prefix("default", "resize"),
1565            "project/default/functions/resize/invocations/"
1566        );
1567        assert_eq!(
1568            keys::idempotency("default", "resize", "k-1"),
1569            "project/default/functions/resize/idem/k-1"
1570        );
1571        assert_eq!(
1572            keys::metering("default", "resize"),
1573            "project/default/metering/resize"
1574        );
1575        // A project other than the default keys under its own segment.
1576        assert_eq!(
1577            keys::meta("acme", "resize"),
1578            "project/acme/functions/resize"
1579        );
1580    }
1581}