Skip to main content

qontinui_types/
functional_spec.rs

1//! Functional Spec (v0) — the backend-agnostic contract between **comprehension**
2//! (which writes the spec from an observed website) and **generation** (which reads
3//! it to regenerate an app + backend).
4//!
5//! This is **Artifact 1** of the functional-spec-contract keystone
6//! (`2026-06-13-functional-spec-contract.md`). It is frozen together with the
7//! Completeness Rubric ([`crate::completeness_verdict`]) and the Priorities Profile
8//! ([`crate::priorities_profile`]) so every downstream plan (app-gen, backend-gen,
9//! comprehension) builds against one stable interface.
10//!
11//! ## The completeness ceiling this encodes
12//!
13//! **Completeness = coverage of frontend-*observable* functionality.** Every node
14//! carries a [`SpecProvenance`] and a free-form `provenance` evidence string. What
15//! the frontend never reveals (server-only validation, hidden business rules) is
16//! recorded as an [`SpecProvenance::Assumed`] node in the [`FunctionalSpec::assumptions`]
17//! ledger — never silently counted as "covered". The Completeness Rubric scores
18//! `observed` + `inferred` coverage separately from `assumed` fill-in (see
19//! [`crate::completeness_verdict`]).
20//!
21//! ## UI states / navigation reuse the typed IR
22//!
23//! [`FunctionalSpec::ui_states`] is `Vec<`[`crate::ir::IrState`]`>` and
24//! [`FunctionalSpec::navigation`] is `Vec<`[`crate::ir::IrTransition`]`>` — the **same
25//! types** `IrPageSpec` carries (`states` / `transitions`) and that Spec-Check
26//! evaluates. This is a literal *type-level* superset of the UI Bridge IR, not a
27//! parallel re-declaration: the app-generator's emitted IR re-parses to the identical
28//! spec subset because a shared type cannot drift from itself. We deliberately do
29//! **not** reuse `crate::state_machine`'s editor/DB-CRUD DTOs here — that is a parallel
30//! representation and binding to it would reintroduce the dual-representation drift the
31//! round-trip invariant exists to prevent.
32//!
33//! ## Wire-format conventions
34//!
35//! Follows the crate conventions ([`crate`] module docs): `camelCase` wire format,
36//! ISO-8601 `String` timestamps, optional fields carry
37//! `#[serde(default, skip_serializing_if = "Option::is_none")]`. `deny_unknown_fields`
38//! is intentionally **omitted** — the v0 stability contract is additive-only, so a
39//! reader must tolerate a forward (newer) document that adds optional fields.
40
41use crate::ir::{IrState, IrTransition};
42use schemars::JsonSchema;
43use serde::{Deserialize, Serialize};
44
45// ===========================================================================
46// Provenance axis (frozen — three values)
47// ===========================================================================
48
49/// How a spec node was established, relative to what the frontend *reveals*.
50///
51/// Named `SpecProvenance` (not `Confidence`) to stay distinct from the
52/// match-confidence axis `crate::spec_check::Confidence`
53/// (`high`/`medium`/`low`), which is orthogonal: that one scores how well an
54/// observed element matched an assertion; this one scores how the spec node
55/// itself was derived from observation.
56///
57/// The three buckets keep the coverage accounting split crisp: the rubric's
58/// denominator is `Observed + Inferred`; `Assumed` is reported separately as
59/// an assumption-fill rate and never folded into the headline coverage number.
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
61#[serde(rename_all = "snake_case")]
62pub enum SpecProvenance {
63    /// Directly evidenced by the frontend — an input exists, a client-side
64    /// validation fires, a route is gated.
65    Observed,
66    /// Reasonably deduced from multiple observations (a relationship, a role)
67    /// but not directly stated. May carry an optional numeric `credibility`.
68    Inferred,
69    /// The frontend is silent; the generator supplies a best-practice default,
70    /// recorded in the [`FunctionalSpec::assumptions`] ledger.
71    Assumed,
72}
73
74// ===========================================================================
75// Top-level spec
76// ===========================================================================
77
78/// The v0 Functional Spec. Five sections, each node confidence- and
79/// provenance-tagged. Serializes as an A2A `DataPart` inside a worker's
80/// `completion_reports.artifacts`; it is the durable, fully re-derivable
81/// hand-off artifact between conductor ticks (orchestration handoff contract §6).
82#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
83#[serde(rename_all = "camelCase")]
84pub struct FunctionalSpec {
85    /// Schema version. Currently always `"0"`. Additive-only until `"1"`.
86    pub spec_version: String,
87
88    /// What was observed and when.
89    pub target: SpecTarget,
90
91    /// Section (1) DOMAIN — entities + relationships inferred from rendered data shapes.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub entities: Vec<Entity>,
94
95    /// Section (2) CAPABILITIES — operations the frontend exposes. Existence is
96    /// high-confidence; server-side effect is low-confidence by construction.
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub operations: Vec<Operation>,
99
100    /// Section (3a) UI STATES — a literal superset of the UI Bridge IR. These are the
101    /// exact `crate::ir::IrState` values `IrPageSpec.states` carries.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub ui_states: Vec<IrState>,
104
105    /// Section (3b) NAVIGATION — the exact `crate::ir::IrTransition` values
106    /// `IrPageSpec.transitions` carries. Reused (not re-declared) so app-gen's
107    /// emitted IR re-parses to the identical spec subset.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub navigation: Vec<IrTransition>,
110
111    /// Section (4) AUTH / PERMISSION MODEL — from login flows + gated routes.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub auth: Option<AuthModel>,
114
115    /// Section (5) ASSUMPTIONS LEDGER — every node with provenance `Assumed`, collated so
116    /// the operator can review/override the generator's best-practice fills. For
117    /// v0 the override surface is a direct edit of this ledger (`overridable`).
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub assumptions: Vec<AssumptionEntry>,
120}
121
122/// What was observed and when.
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
124#[serde(rename_all = "camelCase")]
125pub struct SpecTarget {
126    /// The source website URL the spec was synthesized from.
127    pub source_url: String,
128    /// ISO-8601 UTC timestamp; stamped post-run when the observation completed.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub observed_at: Option<String>,
131}
132
133// ===========================================================================
134// 1. Domain — entities
135// ===========================================================================
136
137/// A domain entity inferred from rendered data shapes.
138#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
139#[serde(rename_all = "camelCase")]
140pub struct Entity {
141    pub name: String,
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub fields: Vec<EntityField>,
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub relationships: Vec<Relationship>,
146    /// How this entity's existence was established.
147    pub confidence: SpecProvenance,
148    /// Free-form evidence string (e.g. `"detail view + list view both render it"`).
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub provenance: Option<String>,
151    /// Optional deduction strength ∈ [0,1]. Present only on `Inferred` nodes;
152    /// meaningless (and omitted) on `Observed` / `Assumed`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub credibility: Option<f64>,
155}
156
157/// A field of an [`Entity`].
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
159#[serde(rename_all = "camelCase")]
160pub struct EntityField {
161    pub name: String,
162    /// Coarse semantic type: `"string"`, `"money"`, `"enum"`, `"date"`,
163    /// `"bool"`, `"number"`, `"reference"`, … Free-form so comprehension can
164    /// introduce new types without a schema bump.
165    #[serde(rename = "type")]
166    pub field_type: String,
167    /// Enumerated values when `field_type == "enum"`.
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub values: Vec<String>,
170    pub confidence: SpecProvenance,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub provenance: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub credibility: Option<f64>,
175}
176
177/// A relationship from one entity to another.
178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
179#[serde(rename_all = "camelCase")]
180pub struct Relationship {
181    /// Target entity name.
182    pub to: String,
183    /// `"one-to-one"`, `"one-to-many"`, `"many-to-one"`, `"many-to-many"`.
184    pub kind: String,
185    pub confidence: SpecProvenance,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub provenance: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub credibility: Option<f64>,
190}
191
192// ===========================================================================
193// 2. Capabilities — operations
194// ===========================================================================
195
196/// An operation the frontend exposes. Existence (`confidence`) is typically
197/// `Observed`; the server-side [`OperationEffect`] is `Assumed` by construction
198/// (the frontend cannot reveal what persists server-side).
199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
200#[serde(rename_all = "camelCase")]
201pub struct Operation {
202    pub name: String,
203    /// `"create"`, `"read"`, `"update"`, `"delete"`, `"custom"`.
204    pub verb: String,
205    /// Target entity, when the operation acts on one.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub entity: Option<String>,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub inputs: Vec<OperationInput>,
210    /// The (largely assumed) server-side effect.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub effect: Option<OperationEffect>,
213    pub confidence: SpecProvenance,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub provenance: Option<String>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub credibility: Option<f64>,
218}
219
220/// One input of an [`Operation`].
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
222#[serde(rename_all = "camelCase")]
223pub struct OperationInput {
224    /// Field name (usually maps to an [`EntityField::name`]).
225    pub field: String,
226    #[serde(default)]
227    pub required: bool,
228    /// Client-side validation rule observed on the input, when any.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub validation: Option<ValidationRule>,
231}
232
233/// A client-side validation rule observed on an input.
234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
235#[serde(rename_all = "camelCase")]
236pub struct ValidationRule {
237    /// The rule expression as observed (e.g. `"> 0"`, `"email"`, `"maxLength 80"`).
238    pub rule: String,
239    pub confidence: SpecProvenance,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub provenance: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub credibility: Option<f64>,
244}
245
246/// The server-side effect of an [`Operation`]. `Assumed` by construction; the
247/// `assumption` field records the best-practice default the generator applied.
248#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
249#[serde(rename_all = "camelCase")]
250pub struct OperationEffect {
251    pub confidence: SpecProvenance,
252    /// The best-practice default applied when `confidence == Assumed`
253    /// (e.g. `"persists + returns created row (REST 201 default)"`).
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub assumption: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub provenance: Option<String>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub credibility: Option<f64>,
260}
261
262// ===========================================================================
263// 4. Auth / permission model
264// ===========================================================================
265
266/// The auth / permission model inferred from login flows + gated routes.
267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
268#[serde(rename_all = "camelCase")]
269pub struct AuthModel {
270    /// `"none"`, `"session"`, `"jwt"`, `"oauth"`, `"basic"`, …
271    pub model: String,
272    pub confidence: SpecProvenance,
273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
274    pub roles: Vec<AuthRole>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub provenance: Option<String>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub credibility: Option<f64>,
279}
280
281/// A role surfaced by the auth model (e.g. inferred from nav items only some
282/// sessions see).
283#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
284#[serde(rename_all = "camelCase")]
285pub struct AuthRole {
286    pub name: String,
287    pub confidence: SpecProvenance,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub provenance: Option<String>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub credibility: Option<f64>,
292}
293
294// ===========================================================================
295// 5. Assumptions ledger
296// ===========================================================================
297
298/// One collated assumption — a node whose provenance is `Assumed`. The operator
299/// reviews/overrides these directly (v0 override surface = a file edit of this
300/// ledger).
301#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
302#[serde(rename_all = "camelCase")]
303pub struct AssumptionEntry {
304    /// Dotted ref into the spec the assumption belongs to, e.g.
305    /// `"operations.createInvoice.effect"`.
306    #[serde(rename = "ref")]
307    pub r#ref: String,
308    /// The best-practice default the generator applied.
309    pub default_applied: String,
310    /// Whether the operator may override this fill. Defaults to `true`.
311    #[serde(default = "default_overridable")]
312    pub overridable: bool,
313    /// Optional free-form operator note (e.g. an override rationale).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub note: Option<String>,
316}
317
318fn default_overridable() -> bool {
319    true
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn provenance_snake_case_round_trip() {
328        for (wire, variant) in [
329            ("\"observed\"", SpecProvenance::Observed),
330            ("\"inferred\"", SpecProvenance::Inferred),
331            ("\"assumed\"", SpecProvenance::Assumed),
332        ] {
333            let parsed: SpecProvenance = serde_json::from_str(wire).unwrap();
334            assert_eq!(parsed, variant);
335            assert_eq!(serde_json::to_string(&parsed).unwrap(), wire);
336        }
337    }
338
339    #[test]
340    fn assumption_overridable_defaults_true_when_absent() {
341        let json =
342            r#"{"ref":"operations.createInvoice.effect","defaultApplied":"REST 201 persist"}"#;
343        let parsed: AssumptionEntry = serde_json::from_str(json).unwrap();
344        assert!(parsed.overridable);
345        assert_eq!(parsed.r#ref, "operations.createInvoice.effect");
346    }
347
348    #[test]
349    fn entity_field_renames_type_keyword() {
350        let f = EntityField {
351            name: "amount".into(),
352            field_type: "money".into(),
353            values: vec![],
354            confidence: SpecProvenance::Observed,
355            provenance: Some("form#invoice input[name=amount]".into()),
356            credibility: None,
357        };
358        let v = serde_json::to_value(&f).unwrap();
359        assert_eq!(v["type"], "money");
360        assert!(
361            v.get("credibility").is_none(),
362            "absent credibility must not serialize"
363        );
364    }
365
366    #[test]
367    fn spec_reuses_ir_state_and_transition_types() {
368        // Compile-time proof the section types ARE the IR types, not parallel
369        // re-declarations. If the IR types change shape, this stops compiling.
370        let _ui: Vec<IrState> = Vec::new();
371        let _nav: Vec<IrTransition> = Vec::new();
372        let spec = FunctionalSpec {
373            spec_version: "0".into(),
374            target: SpecTarget {
375                source_url: "https://example.test".into(),
376                observed_at: None,
377            },
378            entities: vec![],
379            operations: vec![],
380            ui_states: _ui,
381            navigation: _nav,
382            auth: None,
383            assumptions: vec![],
384        };
385        let round: FunctionalSpec =
386            serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
387        assert_eq!(round.spec_version, "0");
388    }
389}