wyrd-spec 0.1.1

Wyrd foundation types: Card envelope, SpecKinds, and pure data contracts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Typed object half of one Wyrd operation/object RBAC grant.
//!
//! Wyrd RBAC is `(resource, action, scope)`. This module owns the `scope` axis:
//! the closed, domain-tagged union naming *which objects* a grant reaches. It
//! lives in the contract layer because the shape is persisted in
//! `wyrd.auth_roles.permissions`, projected into generated schemas, and read by
//! clients; the runtime permission model in `wyrd-runtime` consumes it rather
//! than redefining it.
//!
//! Scope is required on every permission. A grant that targets no object uses
//! [`PermissionScope::All`], which covers every object of the permission's own
//! resource and action and never widens either of those axes.

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::ids::{CardUid, ModelId, ProviderDeploymentName};

/// Longest accepted catalog or schema identifier segment.
const MAX_IDENTIFIER_LEN: usize = 63;

/// Why one permission scope is not a valid object identity.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PermissionScopeError {
    /// A catalog or schema segment is empty, over-long, or carries characters
    /// that are not valid in a Bifrost namespace segment.
    #[error("permission scope {field} is not a valid identifier: {value:?}")]
    InvalidIdentifier {
        /// Which component of the scope failed validation.
        field: &'static str,
        /// The rejected value, retained so the operator can see what was stored.
        value: String,
    },
}

/// Objects one permission grant reaches.
///
/// Domain-tagged rather than a shared string object language: a future Wyrd
/// domain that needs object scope adds its own typed variant instead of
/// overloading someone else's identity spelling.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum PermissionScope {
    /// Every object of the covered resource and action.
    All,
    /// One Bifrost schema or table.
    Bifrost(BifrostPermissionScope),
    /// One gateway deployment or exact deployment/model pair.
    Gateway(GatewayAccess),
    /// One Verifier Card, by its exact UID.
    Verifier(CardUid),
}

/// The gateway deployment objects one invoke grant reaches.
///
/// A deployment grant covers every model that deployment permits; a model
/// grant covers only the exact deployment/model pair. Identities are the same
/// typed values call addressing uses. Unlike the unscoped `all` grant, either
/// variant also reaches a `granted` deployment.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayAccess {
    /// Every model one deployment permits.
    Deployment {
        /// Covered deployment.
        deployment: ProviderDeploymentName,
    },
    /// One exact model addressed through one deployment.
    Model {
        /// Deployment serving the model.
        deployment: ProviderDeploymentName,
        /// Provider-native model identifier.
        model: ModelId,
    },
}

impl GatewayAccess {
    /// True when this gateway grant's objects include every object `required` names.
    #[must_use]
    pub fn covers(&self, required: &Self) -> bool {
        match (self, required) {
            (
                Self::Deployment { deployment },
                Self::Deployment {
                    deployment: required,
                }
                | Self::Model {
                    deployment: required,
                    ..
                },
            ) => deployment == required,
            (Self::Model { .. }, Self::Model { .. }) => self == required,
            (Self::Model { .. }, Self::Deployment { .. }) => false,
        }
    }
}

/// The Bifrost objects one grant reaches.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum BifrostPermissionScope {
    /// Every table currently or later resolved beneath one catalog/schema pair.
    Schema(BifrostSchemaScope),
    /// Exactly one registered table, identified by its stable server-managed UID.
    Table(BifrostTableScope),
}

/// One canonical Bifrost catalog and schema pair.
///
/// These are the *logical* names a caller writes in SQL — catalog `vala`,
/// schema `logs` — not the flattened internal namespace (`vala.logs`) and not
/// the physical Iceberg catalog name.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct BifrostSchemaScope {
    /// Logical catalog, `vala` for every table Bifrost serves today.
    pub catalog: String,
    /// Logical schema beneath the catalog, such as `logs` or `traces`.
    pub schema: String,
}

/// One exact registered Bifrost table.
///
/// The UID is the authority: it is assigned at registration and never reused,
/// so a grant does not follow a dropped-and-recreated table of the same name.
/// The catalog and schema ride alongside it so the synchronous checker can
/// evaluate schema containment without a catalog lookup.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct BifrostTableScope {
    /// Logical catalog the table resolves under.
    pub catalog: String,
    /// Logical schema the table resolves under.
    pub schema: String,
    /// Stable server-managed table UID.
    pub table_uid: Uuid,
}

impl PermissionScope {
    /// True when this scope's objects include every object `required` names.
    ///
    /// [`Self::All`] covers everything; a Bifrost scope never covers `All`,
    /// because "all objects" is strictly wider than any single schema or table.
    /// A Verifier scope covers only the same Verifier UID.
    #[must_use]
    pub fn covers(&self, required: &Self) -> bool {
        match (self, required) {
            (Self::All, _) => true,
            (Self::Bifrost(granted), Self::Bifrost(required)) => granted.covers(required),
            (Self::Gateway(granted), Self::Gateway(required)) => granted.covers(required),
            (Self::Verifier(granted), Self::Verifier(required)) => granted == required,
            (Self::Bifrost(_) | Self::Gateway(_) | Self::Verifier(_), _) => false,
        }
    }

    /// True when this scope names a Bifrost object rather than every object.
    #[must_use]
    pub const fn is_bifrost(&self) -> bool {
        matches!(self, Self::Bifrost(_))
    }

    /// True when this scope names a gateway deployment or model.
    #[must_use]
    pub const fn is_gateway(&self) -> bool {
        matches!(self, Self::Gateway(_))
    }

    /// Rejects a scope whose object identity is malformed.
    ///
    /// # Errors
    ///
    /// Returns [`PermissionScopeError::InvalidIdentifier`] when a catalog or
    /// schema segment is empty, longer than 63 characters, or contains anything
    /// other than ASCII alphanumerics, `_`, or `-`. Gateway identities are
    /// validated when their typed ids decode, so a gateway or Verifier scope is
    /// always valid.
    pub fn validate(&self) -> Result<(), PermissionScopeError> {
        match self {
            Self::All | Self::Gateway(_) | Self::Verifier(_) => Ok(()),
            Self::Bifrost(scope) => scope.validate(),
        }
    }
}

impl BifrostPermissionScope {
    /// True when this Bifrost scope's objects include every object `required` names.
    #[must_use]
    pub fn covers(&self, required: &Self) -> bool {
        match (self, required) {
            (Self::Schema(granted), Self::Schema(required)) => granted == required,
            (Self::Schema(granted), Self::Table(required)) => {
                granted.catalog == required.catalog && granted.schema == required.schema
            }
            (Self::Table(granted), Self::Table(required)) => granted == required,
            (Self::Table(_), Self::Schema(_)) => false,
        }
    }

    /// Rejects a Bifrost scope whose catalog or schema identity is malformed.
    ///
    /// # Errors
    ///
    /// Returns [`PermissionScopeError::InvalidIdentifier`] for an empty,
    /// over-long, or non-identifier catalog or schema segment.
    pub fn validate(&self) -> Result<(), PermissionScopeError> {
        let (catalog, schema) = match self {
            Self::Schema(scope) => (&scope.catalog, &scope.schema),
            Self::Table(scope) => (&scope.catalog, &scope.schema),
        };
        validate_identifier("catalog", catalog)?;
        validate_identifier("schema", schema)
    }

    /// Borrows the logical catalog this scope names.
    #[must_use]
    pub fn catalog(&self) -> &str {
        match self {
            Self::Schema(scope) => &scope.catalog,
            Self::Table(scope) => &scope.catalog,
        }
    }

    /// Borrows the logical schema this scope names.
    #[must_use]
    pub fn schema(&self) -> &str {
        match self {
            Self::Schema(scope) => &scope.schema,
            Self::Table(scope) => &scope.schema,
        }
    }
}

/// Rejects a catalog or schema segment that is not a safe namespace identifier.
///
/// # Errors
///
/// Returns [`PermissionScopeError::InvalidIdentifier`] naming `field` when
/// `value` is empty, longer than 63 characters, or carries a character outside
/// ASCII alphanumerics, `_`, and `-`.
fn validate_identifier(field: &'static str, value: &str) -> Result<(), PermissionScopeError> {
    let valid = !value.is_empty()
        && value.len() <= MAX_IDENTIFIER_LEN
        && value.chars().all(|character| {
            character.is_ascii_alphanumeric() || character == '_' || character == '-'
        });
    if valid {
        Ok(())
    } else {
        Err(PermissionScopeError::InvalidIdentifier {
            field,
            value: value.to_owned(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::{
        BifrostPermissionScope, BifrostSchemaScope, BifrostTableScope, PermissionScope,
        PermissionScopeError,
    };
    use uuid::Uuid;

    /// Builds one table scope under `vala` for the supplied schema and UID.
    fn table(schema: &str, uid: Uuid) -> PermissionScope {
        PermissionScope::Bifrost(BifrostPermissionScope::Table(BifrostTableScope {
            catalog: "vala".to_owned(),
            schema: schema.to_owned(),
            table_uid: uid,
        }))
    }

    /// Builds one schema scope under `vala` for the supplied schema.
    fn schema(schema: &str) -> PermissionScope {
        PermissionScope::Bifrost(BifrostPermissionScope::Schema(BifrostSchemaScope {
            catalog: "vala".to_owned(),
            schema: schema.to_owned(),
        }))
    }

    /// Proves `All` is the top of the object lattice: it covers every scope and
    /// no narrower scope can ever satisfy an object-wide requirement.
    #[test]
    fn all_covers_every_object_and_is_covered_by_nothing_narrower() {
        let uid = Uuid::from_u128(1);

        assert!(PermissionScope::All.covers(&PermissionScope::All));
        assert!(PermissionScope::All.covers(&schema("logs")));
        assert!(PermissionScope::All.covers(&table("logs", uid)));
        assert!(!schema("logs").covers(&PermissionScope::All));
        assert!(!table("logs", uid).covers(&PermissionScope::All));
    }

    /// Proves a schema grant reaches current and future tables beneath exactly
    /// that catalog/schema pair, and never another schema.
    #[test]
    fn schema_scope_covers_every_table_in_that_exact_schema() {
        let one = Uuid::from_u128(1);
        let two = Uuid::from_u128(2);

        assert!(schema("logs").covers(&table("logs", one)));
        assert!(schema("logs").covers(&table("logs", two)));
        assert!(!schema("logs").covers(&table("traces", one)));
        assert!(!schema("logs").covers(&schema("traces")));
    }

    /// Proves a table grant follows the stable UID alone: it covers no sibling
    /// table and never widens back to its own schema.
    #[test]
    fn table_scope_covers_only_the_exact_uid() {
        let one = Uuid::from_u128(1);
        let two = Uuid::from_u128(2);

        assert!(table("traces", one).covers(&table("traces", one)));
        assert!(!table("traces", one).covers(&table("traces", two)));
        assert!(!table("traces", one).covers(&schema("traces")));
    }

    /// Proves the persisted scope JSON is exactly the approved domain-tagged
    /// projection, so the wire form cannot drift from the spec.
    #[test]
    fn scope_json_matches_the_approved_projection() {
        let uid = Uuid::from_u128(0x1234);

        assert_eq!(
            serde_json::to_value(PermissionScope::All).expect("all serializes"),
            serde_json::json!("all")
        );
        assert_eq!(
            serde_json::to_value(schema("logs")).expect("schema scope serializes"),
            serde_json::json!({"bifrost": {"schema": {"catalog": "vala", "schema": "logs"}}})
        );
        assert_eq!(
            serde_json::to_value(table("traces", uid)).expect("table scope serializes"),
            serde_json::json!({
                "bifrost": {"table": {
                    "catalog": "vala",
                    "schema": "traces",
                    "table_uid": uid.to_string(),
                }}
            })
        );
    }

    /// Proves object identities are validated, not merely parsed: an empty or
    /// over-long segment and a flattened namespace are all refused.
    #[test]
    fn malformed_identities_are_rejected() {
        assert!(PermissionScope::All.validate().is_ok());
        assert!(schema("logs").validate().is_ok());
        assert_eq!(
            schema("").validate(),
            Err(PermissionScopeError::InvalidIdentifier {
                field: "schema",
                value: String::new(),
            })
        );
        assert!(
            PermissionScope::Bifrost(BifrostPermissionScope::Schema(BifrostSchemaScope {
                catalog: "vala.logs".to_owned(),
                schema: "logs".to_owned(),
            }))
            .validate()
            .is_err(),
            "a flattened namespace is not a catalog identifier"
        );
        assert!(
            schema(&"x".repeat(64)).validate().is_err(),
            "an over-long schema segment is rejected"
        );
    }

    /// Proves a deployment grant covers that deployment's models and a model
    /// grant covers only its exact pair, with the approved wire projection.
    ///
    /// # Panics
    /// Panics when a fixture name is invalid or a scope covers another set of
    /// models.
    #[test]
    fn gateway_scope_covers_deployment_models_and_exact_pairs() {
        use super::GatewayAccess;
        use crate::ids::{ModelId, ProviderDeploymentName};
        let provider = |name: &str| {
            PermissionScope::Gateway(GatewayAccess::Deployment {
                deployment: ProviderDeploymentName::new(name).expect("valid deployment"),
            })
        };
        let model = |name: &str, model: &str| {
            PermissionScope::Gateway(GatewayAccess::Model {
                deployment: ProviderDeploymentName::new(name).expect("valid deployment"),
                model: ModelId::new(model).expect("valid model"),
            })
        };

        assert!(provider("openai").covers(&model("openai", "gpt-4o")));
        assert!(provider("openai").covers(&provider("openai")));
        assert!(!provider("openai").covers(&model("anthropic", "claude")));
        assert!(model("openai", "gpt-4o").covers(&model("openai", "gpt-4o")));
        assert!(!model("openai", "gpt-4o").covers(&model("openai", "gpt-4.1")));
        assert!(!model("openai", "gpt-4o").covers(&provider("openai")));
        assert!(!provider("openai").covers(&PermissionScope::All));
        assert!(!provider("openai").covers(&schema("logs")));
        assert!(!schema("logs").covers(&provider("openai")));
        assert!(PermissionScope::All.covers(&model("openai", "gpt-4o")));
        assert_eq!(
            serde_json::to_value(model("openai", "gpt-4o")).expect("scope serializes"),
            serde_json::json!({"gateway": {"model": {"deployment": "openai", "model": "gpt-4o"}}})
        );
        assert!(
            serde_json::from_value::<PermissionScope>(
                serde_json::json!({"gateway": {"deployment": {"deployment": "OpenAI"}}})
            )
            .is_err()
        );
        assert!(
            serde_json::from_value::<PermissionScope>(
                serde_json::json!({"gateway": {"provider": {"provider": "openai"}}})
            )
            .is_err(),
            "the retired provider scope does not decode"
        );
    }

    /// Proves a non-UUID table identity fails at decode, so no unresolvable
    /// object identity can reach the synchronous checker.
    #[test]
    fn malformed_table_identity_is_rejected_at_decode() {
        let error = serde_json::from_value::<PermissionScope>(serde_json::json!({
            "bifrost": {"table": {"catalog": "vala", "schema": "traces", "table_uid": "not-a-uid"}}
        }))
        .expect_err("a non-UUID table identity does not decode");

        assert!(error.to_string().contains("UUID") || error.to_string().contains("uuid"));
    }
}