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}