qubit-validator 0.1.0

Type-safe validation traits and registries for Qubit applications
Documentation
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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# Qubit Validator user guide

[README](../README.md) · [简体中文](user_guide.zh_CN.md) · [API reference](https://docs.rs/qubit-validator)

This guide covers `qubit-validator` 0.1.x on Rust 1.94 or newer. It is for developers adding typed validation to a Rust application, especially when several request paths select rules from configuration. Follow [Integrate a profile update](#integrate-a-profile-update) and [Check the result before saving](#check-the-result-before-saving) for the basic path. The remaining sections answer specific questions about dependencies, parameters, reports, registration, and failures.

## Contents

- [The problem it solves](#the-problem-it-solves)
- [Integrate a profile update](#integrate-a-profile-update)
- [Check the result before saving](#check-the-result-before-saving)
- [Compare two profile fields](#compare-two-profile-fields)
- [Configure a rule once at startup](#configure-a-rule-once-at-startup)
- [Collect several checks and handle limits](#collect-several-checks-and-handle-limits)
- [Choose registration and adapter options](#choose-registration-and-adapter-options)
- [Choose stable IDs and violation codes](#choose-stable-ids-and-violation-codes)
- [Diagnose failures and respect boundaries](#diagnose-failures-and-respect-boundaries)

## The problem it solves

A profile update receives a `display_name`. The application must reject a name containing only whitespace and tell the client which field failed, while leaving the submitted text out of diagnostics. At first, a direct Rust rule may be enough. When multiple entry points choose rules from configuration, the application also needs stable rule IDs, an error if startup configuration cannot be bound, and a report it can map to its own response format.

`qubit-validator` separates those steps. `Validator<T, C>` is ordinary typed rule code. A descriptor and registry bind a selected rule once; the bound validator checks input shape on each call. `ValidationReport` attaches the caller's field path to a result. The application still chooses fields, schedules rules, decides whether to save, and creates client messages. The crate neither walks profile objects nor stores data.

The repository's [local registry example](../examples/local_registry.rs) runs with `cargo run --example local_registry --locked`. It checks the same `text.non_blank` binding with accepted and rejected names, plus named dependency binding and parameter consumption. It does not implement the profile store or collect a report; the application integration below shows those boundaries. For annotation-driven whole-object validation, see the [model annotations section](../README.md#model-annotations) for the local dependency setup and the [model validation guide](https://github.com/qubit-ltd/rs-model-metadata/blob/main/doc/user_guide.md#core-workflow-validate-a-profile-and-keep-partial-results) for the complete model-level workflow.

## Integrate a profile update

Add the crate to the application's `Cargo.toml`:

```toml
[dependencies]
qubit-validator = "0.1"
```

The default features are enough for this path. Define the business rule in the profile module. Its domain error stays useful to code that calls the rule directly; the configured path will map it to a stable violation code.

<!-- validator-guide:rules -->
```rust
// src/profile/rules.rs
use std::{error::Error, fmt, sync::Arc};
use qubit_validator::{
    ArgumentReader, BindError, InputType,
    PreparedValidator, Validator, ValidatorDescriptor, ValidatorSignature,
    ViolationCode, ViolationDraft, prepare_text_validator,
};

#[derive(Debug)]
pub struct BlankText;
impl fmt::Display for BlankText {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("text must not be blank")
    }
}
impl Error for BlankText {}

pub struct NonBlank;
impl Validator<str> for NonBlank {
    type Error = BlankText;
    fn validate(&self, value: &str, _: &()) -> Result<(), Self::Error> {
        if value.trim().is_empty() { Err(BlankText) } else { Ok(()) }
    }
}

fn prepare(_reader: &mut ArgumentReader<'_>)
    -> Result<Arc<dyn PreparedValidator>, BindError>
{
    Ok(prepare_text_validator(NonBlank, |_| {
        ViolationDraft::new(ViolationCode::new("text.blank"))
    }))
}

static SIGNATURES: &[ValidatorSignature] = &[
    ValidatorSignature::new(InputType::Text, &[], prepare),
];
pub static DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(SIGNATURES);
```

The binding layer rejects unexpected parameters after this factory succeeds, because the rule consumes none. The mapper uses a fixed code and does not copy the rejected name or the domain error's text into the violation. At application startup, register and bind the rule. Keep the resulting `BoundValidator` in the profile service rather than rebuilding it for every request:

<!-- validator-guide:startup -->
```rust
// src/profile/startup.rs
use qubit_validator::{
    BoundValidator, InputType, RegistrationSource, ValidatorId,
    ValidatorRegistration, ValidatorRegistry,
};
use crate::profile::rules::DESCRIPTOR;

pub fn bind_display_name_rule() -> Result<BoundValidator, Box<dyn std::error::Error>> {
    let registration = ValidatorRegistration::new(
        ValidatorId::new("text.non_blank"),
        &DESCRIPTOR,
        RegistrationSource::new(env!("CARGO_PKG_NAME"), module_path!(), file!(), line!()),
    );
    let registry = ValidatorRegistry::from_registrations([registration])?;
    let configured_rule_id = String::from("text.non_blank");
    Ok(registry.bind(configured_rule_id.as_str(), InputType::Text, &[])?)
}
```

`String::from` stands in for an owned string read from configuration; the application supplies its actual configuration source. Binding accepts a runtime configuration string, so it does not need to be `'static'` and does not need to be converted to a `ValidatorId` first. `from_registrations` detects duplicate IDs and invalid descriptors; `bind` selects the text signature and prepares the rule. Handle either error before accepting profile requests. This small function returns one bound rule; an application with many rules can keep the registry and bind each selected ID at startup.

For each request, the profile service validates the borrowed name and records the result at a path chosen by the application:

<!-- validator-guide:service -->
```rust
// src/profile/service.rs
use qubit_validator::{
    BoundValidationContext, BoundValidator, ValidationPath,
    ValidationReport, ValidationValue,
};

fn check_display_name(
    rule: &BoundValidator,
    display_name: &str,
) -> Result<ValidationReport, Box<dyn std::error::Error>> {
    let outcome = rule.validate(
        ValidationValue::Text(display_name),
        &BoundValidationContext::new(&[]),
    )?;
    let mut report = ValidationReport::new();
    report.record_outcome(
        0,
        ValidationPath::root().with_field("display_name"),
        outcome,
    )?;
    Ok(report)
}
```

`0` is the occurrence number in this request's validation plan. The crate does not inspect the profile struct to find the field. For `"Ada"`, the report is valid; for `"   "`, it contains one `text.blank` violation at `display_name`. The profile service checks the report before using its own storage interface.

### Types used on this path

| Type | Role in the profile update |
| --- | --- |
| `Validator<str>` | Implements the business rule and its domain error. |
| `ValidatorSignature` / `ValidatorDescriptor` | Declare text input, dependencies, and preparation. |
| `ValidatorRegistry` | Finds a rule by stable ID and binds its selected signature. |
| `BoundValidator` | Reuses the prepared rule and checks input shape. |
| `ValidationOutcome` | Says valid, invalid, or skipped after execution. |
| `ValidationReport` | Records outcomes at caller-selected paths and occurrence numbers. |

## Check the result before saving

The application supplies `ProfileStore` and the HTTP handler. First confirm that the entire intended validation run returned successfully; only then use the report to make the pass-before-save decision. A partial report attached to an execution error is diagnostic evidence only:

<!-- validator-guide:service-continuation -->
```rust
// Continue in src/profile/service.rs; check_display_name is defined above.
trait ProfileStore {
    fn save_display_name(
        &self,
        user_id: &str,
        display_name: &str,
    ) -> Result<(), Box<dyn std::error::Error>>;
}

enum UpdateProfileResult {
    Updated,
    Rejected(ValidationReport),
}

fn update_profile(
    store: &dyn ProfileStore,
    rule: &BoundValidator,
    user_id: &str,
    incoming_display_name: &str,
) -> Result<UpdateProfileResult, Box<dyn std::error::Error>> {
    let report = check_display_name(rule, incoming_display_name)?;
    if report.is_truncated() {
        return Err(std::io::Error::other("validation report is incomplete").into());
    }
    if !report.is_valid() {
        return Ok(UpdateProfileResult::Rejected(report));
    }
    store.save_display_name(user_id, incoming_display_name)?;
    Ok(UpdateProfileResult::Updated)
}
```

The store and response type are application code, not provided by the crate. `"Ada"` reaches `save_display_name` and returns `Updated` if that call succeeds. A blank name returns `Rejected(report)` without a store call. A store error remains an application error. The handler should check `is_truncated()` first, then iterate `report.violations()` and map each code to a controlled message. An invalid report can contain no retained violation, so do not index the first item without checking it. If a model-validation integration returns an execution error with a partial report, keep that report for diagnosis and do not use it to authorize a write. Keep the submitted name out of messages and logs.

| What happened | What to inspect | Application action |
| --- | --- | --- |
| `"Ada"` passes | `report.is_valid()` is true after all intended checks complete successfully | Save through the application's store. |
| `"   "` fails the rule | `ValidationOutcome::Invalid`, then `report.violations()` contains `text.blank` | Reject the update and map the code to a field message. |
| ID, signature, or parameters are wrong | `ValidatorRegistryError` or `BindError` during startup | Fix configuration before serving requests. |
| Input shape or rule execution fails | `Err(ExecutionError)` from `validate` | Treat as a service failure, not invalid user data; diagnose through a trusted path. |
| A limited report cannot retain everything | `!recorded.complete()` or `report.is_truncated()` | Treat the report as incomplete; do not accept the update based on missing violations. |

`record_outcome` returns `RecordedOutcome`; `complete()` means that outcome fitted the report's limits, **not** that validation passed. This example uses `ValidationReport::new()` without count limits. For a limited report, check completeness and truncation as well as validity. `ValidationOutcome::Invalid` means a rule ran and rejected a value; it is not an `ExecutionError`. In every case, first confirm the full intended run returned successfully; an execution error's partial report is for diagnosis, not acceptance.

The basic path ends here: bind at startup, validate in the request, and save only after a valid report. The following sections extend that same profile flow.

## Compare two profile fields

Suppose the profile form has `email` and `email_confirmation`. The confirmation rule needs the chosen email as a dependency. Declare the dependency once in the rule's signature and adapter, then bind it at startup. This self-contained module defines the rule, binds it at startup, and exposes a request-time call:

<!-- validator-guide:confirmation -->
```rust
// src/profile/confirmation.rs
use std::sync::Arc;
use qubit_validator::{
    ArgumentReader, BindError, BoundValidator, DependencySpec, ExecutionError,
    InputType, NamedValidationDependency,
    PreparedOutcome, PreparedValidator, RegistrationSource, ValidationOutcome,
    ValidationValue, ValidatorDescriptor, ValidatorId, ValidatorRegistration,
    ValidatorRegistry, ValidatorSignature, ViolationCode, ViolationDraft,
    prepare_text_with_context,
};

static EMAIL_DEPENDENCIES: &[DependencySpec] = &[
    DependencySpec::new("email", InputType::Text, false),
];

fn prepare_confirmation(_reader: &mut ArgumentReader<'_>)
    -> Result<Arc<dyn PreparedValidator>, BindError>
{
    Ok(prepare_text_with_context(EMAIL_DEPENDENCIES, |confirmation, context| {
        let email = context.text(0)?;
        Ok(if confirmation == email {
            PreparedOutcome::Valid
        } else {
            PreparedOutcome::Invalid(vec![ViolationDraft::new(
                ViolationCode::new("email.confirmation_mismatch"),
            )])
        })
    }))
}

static SIGNATURES: &[ValidatorSignature] = &[
    ValidatorSignature::new(InputType::Text, EMAIL_DEPENDENCIES, prepare_confirmation),
];
static DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(SIGNATURES);

pub fn bind_confirmation_rule() -> Result<BoundValidator, Box<dyn std::error::Error>> {
    let registration = ValidatorRegistration::new(
        ValidatorId::new("email.confirmation"),
        &DESCRIPTOR,
        RegistrationSource::new(env!("CARGO_PKG_NAME"), module_path!(), file!(), line!()),
    );
    let registry = ValidatorRegistry::from_registrations([registration])?;
    Ok(registry.bind("email.confirmation", InputType::Text, &[])?)
}

pub fn check_confirmation(
    rule: &BoundValidator,
    email: &str,
    confirmation: &str,
) -> Result<ValidationOutcome, ExecutionError> {
    let dependencies = [NamedValidationDependency::new(
        "email", ValidationValue::Text(email),
    )];
    rule.validate_named(ValidationValue::Text(confirmation), &dependencies)
}
```

Call `bind_confirmation_rule()` during startup and keep its result in the profile service. During a request, call `check_confirmation(&rule, email, confirmation)` and record that outcome at `email_confirmation`. A larger application can register the descriptors together in one registry. The adapter reads the declared slot through `context.text(0)?`; a missing or malformed slot is an execution error.

Matching values yield `Valid`; different values yield `email.confirmation_mismatch`. Missing or wrongly typed required dependencies produce `ExecutionError`, which the service handles as a failure rather than a field violation. `validate_named` maps names to signature slots. It does not allocate a reorder container for zero or one dependency; two to eight dependencies use a fixed-capacity temporary buffer, and nine or more allocate that buffer on demand. Cloning a non-root `ValidationPath` may still allocate. If the caller has already established slot order, use `validate` with `BoundValidationContext::new(&values)`. Optional slots still occupy a position; use `ValidationValue::Missing` for absence. A wrong type is not absence.

Dependency paths are supplied in the same slot order as their values and are kept unchanged. The caller chooses the coordinate system; neither `new_with_paths` nor `NamedValidationDependency::with_path` adds the target path. For example, a model adapter can pass the dependency's `profile.email` location to its context:

```rust
use qubit_validator::{BoundValidationContext, ExecutionError, ValidationPath, ValidationValue};

fn main() -> Result<(), ExecutionError> {
    let values = [ValidationValue::Text("ada@example.test")];
    let paths = [ValidationPath::root().with_field("profile").with_field("email")];
    let context = BoundValidationContext::new_with_paths(&values, &paths)?;
    assert_eq!(context.dependency_path(0)?.render(), "profile.email");
    Ok(())
}
```

When an adapter returns an error for a declared dependency, attach that slot's name and path. Model validation matches the name to the declaration and keeps the dependency location separate from the target. A `confirmation` rule on `profile.confirmation` that reads `profile.email` therefore reports at `profile.email`; an external `..` dependency on `nickname` keeps `nickname` as its property path and retains `..` as separate object navigation. Ordinary rule errors continue to use a path relative to the current target.

## Configure a rule once at startup

If profile policy adds a maximum display-name length, read its `maximum` parameter while binding, then reuse that limit for every request. This module includes preparation and startup binding; it counts Unicode scalar values, so an application requiring grapheme or byte limits must define a different rule.

<!-- validator-guide:max-length -->
```rust
// src/profile/max_length.rs
use std::sync::Arc;
use qubit_validator::{
    ArgumentReader, BindError, BoundValidator, InputType, NamedValidationArgument,
    PreparedOutcome, PreparedValidator, RegistrationSource, ValidationArgument,
    ValidatorDescriptor, ValidatorId, ValidatorRegistration, ValidatorRegistry,
    ValidatorSignature, ViolationCode, ViolationDraft, prepare_text_with_context,
};

fn prepare_max_length(reader: &mut ArgumentReader<'_>)
    -> Result<Arc<dyn PreparedValidator>, BindError>
{
    let maximum = reader.required_u32("maximum")?;
    Ok(prepare_text_with_context(&[], move |name, _| {
        Ok(if name.chars().count() <= maximum as usize {
            PreparedOutcome::Valid
        } else {
            PreparedOutcome::Invalid(vec![ViolationDraft::new(
                ViolationCode::new("text.too_long"),
            )])
        })
    }))
}

static SIGNATURES: &[ValidatorSignature] = &[
    ValidatorSignature::new(InputType::Text, &[], prepare_max_length),
];
static DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(SIGNATURES);

pub fn bind_max_length_rule(maximum: u32)
    -> Result<BoundValidator, Box<dyn std::error::Error>>
{
    let registration = ValidatorRegistration::new(
        ValidatorId::new("text.max_length"),
        &DESCRIPTOR,
        RegistrationSource::new(env!("CARGO_PKG_NAME"), module_path!(), file!(), line!()),
    );
    let registry = ValidatorRegistry::from_registrations([registration])?;
    let args = [NamedValidationArgument::new(
        "maximum", ValidationArgument::Unsigned(u128::from(maximum)),
    )];
    Ok(registry.bind("text.max_length", InputType::Text, &args)?)
}
```

Call `bind_max_length_rule(40)` at startup, then validate `display_name` with the bound length rule and record its outcome at the same path as the nonblank rule, using the next occurrence number. A name of at most 40 scalar values passes; a longer one produces `text.too_long`. Missing, wrongly typed, out-of-range, or unconsumed parameters fail at binding. `ArgumentReader` consumes each present parameter on the first typed read even if conversion fails; fix the configuration and bind again rather than rereading the same reader.

## Collect several checks and handle limits

The profile service decides the order of rules. Call `record_outcome` with non-decreasing occurrence numbers; multiple results may share a number. An earlier number returns `ValidationOutcomeError::OutOfOrderOccurrence` without changing the report. For example, if `email` is blank, the application can skip `email_confirmation` and point that skip to the original failure:

```rust
// non_blank_rule is the text.non_blank rule bound at startup.
use qubit_validator::{
    BoundValidationContext, ValidationOutcome, ValidationPath,
    ValidationReport, ValidationValue,
};
let mut report = ValidationReport::new();
let email_outcome = non_blank_rule.validate(
    ValidationValue::Text("   "),
    &BoundValidationContext::new(&[]),
)?;
let original = report.record_outcome(
    0,
    ValidationPath::root().with_field("email"),
    email_outcome,
)?;
let skipped = report.record_outcome(
    1,
    ValidationPath::root().with_field("email_confirmation"),
    ValidationOutcome::failed_prerequisite(original.failure_ids().to_vec())?,
)?;
assert!(original.complete() && skipped.complete());
assert_eq!(report.failure_count(), 1);
assert_eq!(report.skipped().len(), 1);
```

Only use `failed_prerequisite` when an earlier result actually retained a failure. Its `FailureId` values belong to this report and point to original violations; `report.failures()` visits each original failure once. The crate does not decide to skip a rule for you. If an optional target is missing, `ValidationOutcome::missing_optional()` records a different skip reason.

For a large request, `ValidationReport::with_limits(ValidationLimits { max_violations: Some(20), max_skipped: Some(20) })` limits retained violations and skipped entries independently. A skip's reference to an already retained failure does not consume another violation slot. Check the returned `RecordedOutcome::complete()` and `report.is_truncated()`; if application code stops checking early, call `report.mark_truncated()`. A truncated report must not be treated as proof that all remaining fields passed. These limits do not bound input length, validation work, dependency paths, prerequisite lists, or outcomes allocated before recording; the application sets those budgets separately.

If execution stops with an error and an integration returns the collected partial report, use it to diagnose recorded evidence only. Confirm the entire intended run succeeded before using `is_valid()` to permit an action.

## Choose registration and adapter options

Explicit `ValidatorRegistry::from_registrations` keeps rule sets local and lets startup code decide which rules are available. If separately linked crates must contribute rules process-wide, enable the optional feature:

```toml
[dependencies]
qubit-validator = { version = "0.1", features = ["inventory"] }
```

Use `register_validator!` at the rule definition and `ValidatorRegistry::try_global()` at startup. `try_global` reports duplicate IDs or invalid descriptors; `global()` panics on those errors. Discovery changes how rules enter the registry, not how they bind or execute. If a `ValidatorRegistration` constant should also be used in a local registry, pass its path with `registration = SHARED_REGISTRATION`; this reuses the ID, descriptor, and source metadata stored in that value. The `id`/`descriptor` form instead records the macro call site's source. Both forms require the `inventory` feature and statically valid IDs; the literal used by the `id` form is checked at compile time. See [`register_validator!` API documentation](https://docs.rs/qubit-validator/latest/qubit_validator/macro.register_validator.html) for a shared-registration example. The [repository example](../examples/local_registry.rs) includes the feature-gated path.

The simple `prepare_text_validator` and `prepare_typed_validator` adapters map a typed rule's domain error to one violation draft. For rules that read declared dependencies, use their contextual counterparts. Domain error values are consumed during mapping and need not be `Send + Sync`; stored validators, domain-call and mapper closures, and prepared adapters retain their thread-safety bounds. If a rule must return several violations or distinguish an external failure from invalid input, `prepare_text_with_context` and `prepare_typed_with_context` return `PreparedOutcome` or `ExecutionError`; [`prepare_text_domain_rule`](https://docs.rs/qubit-validator/latest/qubit_validator/fn.prepare_text_domain_rule.html) and [`prepare_typed_domain_rule`](https://docs.rs/qubit-validator/latest/qubit_validator/fn.prepare_typed_domain_rule.html) map contextual domain errors into safe drafts. Use the typed factory when the rule validates an exact Rust value type; use the text factory for string input. An invalid draft list cannot be empty. `BoundValidator::try_from_prepared::<T>` binds an already prepared, dependency-free rule without a registry when its input type is exactly `T`.

The directory lookup below is an application-provided stand-in, not a real repository. The maximum name length is rule configuration and is safe report metadata. A domain rejection becomes two violations in the report; a lookup failure returns an `ExecutionError` directly and is not recorded. Use `is_valid()` to decide whether to save only after every planned check has completed successfully. The documentation example tests compile and run this program.

<!-- validator-guide:domain-rule -->
```rust
use std::sync::Arc;
use qubit_validator::{
    ArgumentReader, BindError, DependencySpec, ExecutionError, ExecutionErrorKind,
    InputType, NamedValidationDependency, PreparedValidator, RegistrationSource,
    ValidationOutcome, ValidationPath, ValidationReport, ValidationValue,
    ValidatorDescriptor, ValidatorId, ValidatorRegistration, ValidatorRegistry,
    ValidatorSignature, ViolationCode, ViolationDraft, ViolationParam,
    prepare_text_domain_rule,
};

// Application-provided stand-in for a directory lookup.
fn directory_available(email: &str) -> Result<bool, ExecutionError> {
    if email == "offline@example.test" {
        Err(ExecutionError::new(ExecutionErrorKind::ExternalFailure))
    } else {
        Ok(true)
    }
}
fn prepare(_: &mut ArgumentReader<'_>) -> Result<Arc<dyn PreparedValidator>, BindError> {
    Ok(prepare_text_domain_rule(
        DEPENDENCIES, 40_usize,
        |maximum, name, context| {
            let email = context.text(0)?;
            let available = directory_available(email)?;
            let domain_result = if available && !name.trim().is_empty()
                && name.chars().count() <= *maximum
            {
                Ok(())
            } else {
                Err(std::io::Error::other("profile policy rejected"))
            };
            Ok((domain_result, *maximum))
        },
        |_, maximum| vec![
            ViolationDraft::new(ViolationCode::new("profile.name_policy"))
                .with_param("maximum", ViolationParam::Unsigned(*maximum as u128)),
            ViolationDraft::new(ViolationCode::new("profile.update_rejected")),
        ],
    ))
}
static DEPENDENCIES: &[DependencySpec] = &[DependencySpec::new("email", InputType::Text, false)];
static SIGNATURES: &[ValidatorSignature] = &[
    ValidatorSignature::new(InputType::Text, DEPENDENCIES, prepare),
];
static DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(SIGNATURES);

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let id = ValidatorId::new("profile.name_policy");
    let registration = ValidatorRegistration::new(
        id, &DESCRIPTOR,
        RegistrationSource::new("profile-app", "profile", file!(), line!()),
    );
    let registry = ValidatorRegistry::from_registrations([registration])?;
    let rule = registry.bind("profile.name_policy", InputType::Text, &[])?;
    let dependencies = [NamedValidationDependency::new(
        "email", ValidationValue::Text("ada@example.test"),
    )];
    assert_eq!(rule.validate_named(ValidationValue::Text("Ada"), &dependencies)?, ValidationOutcome::Valid);
    let rejected = rule.validate_named(ValidationValue::Text("   "), &dependencies)?;
    let mut report = ValidationReport::new();
    let receipt = report.record_outcome(
        0, ValidationPath::root().with_field("display_name"), rejected,
    )?;
    assert!(receipt.complete());
    assert!(!report.is_truncated());
    assert!(!report.is_valid());
    assert_eq!(report.failure_count(), 2);
    assert_eq!(report.violations()[0].rule_id(), id);
    assert_eq!(report.violations()[0].path().render(), "display_name");
    assert_eq!(report.violations()[0].code().as_str(), "profile.name_policy");
    assert_eq!(report.violations()[1].code().as_str(), "profile.update_rejected");
    let should_save = report.is_valid();
    assert!(!should_save);
    let offline = [NamedValidationDependency::new(
        "email", ValidationValue::Text("offline@example.test"),
    )];
    assert_eq!(rule.validate_named(ValidationValue::Text("Ada"), &offline)
        .unwrap_err().kind(), ExecutionErrorKind::ExternalFailure);
    assert_eq!(report.failure_count(), 2);
    assert_eq!(rule.validate_named(ValidationValue::Text("Ada"), &[])
        .unwrap_err().kind(), ExecutionErrorKind::MissingDependencyBinding);
    assert_eq!(report.failure_count(), 2);
    Ok(())
}
```

## Choose stable IDs and violation codes

`ValidatorId` names a rule, and `ViolationCode` identifies a reported violation. Both values are non-empty strings made of dot-separated segments. Each segment starts with an ASCII letter and continues with ASCII letters, digits, or underscores. Empty segments, whitespace, non-ASCII letters, and hyphens are rejected; construction does not trim or change case. For example, `app.valid_rule` and `text.length.minimum` are valid, while `bad..id`, `_custom.invalid`, and `text.min-length` are invalid.

Both `ValidatorId::try_new` and `ViolationCode::try_new` require a `&'static str`; they report syntax validation failures and are not for runtime configuration strings. They can still validate program-declared IDs or violation codes that already have static storage but may be syntactically invalid. Select a rule from a runtime ID with `ValidatorRegistry::get` or `bind`; when binding an unknown ID, `bind` returns `MissingRule`, and the caller retains the requested text. See [Configure a rule once at startup](#configure-a-rule-once-at-startup) for the binding example. Violation codes remain statically declared by the program; do not expose configuration strings as violation codes. The `new` constructors panic on invalid input. A literal ID passed to `register_validator!` is checked at compile time. See the [`ValidatorId` API](https://docs.rs/qubit-validator/latest/qubit_validator/struct.ValidatorId.html) and [`ViolationCode` API](https://docs.rs/qubit-validator/latest/qubit_validator/struct.ViolationCode.html) for the exact error variants and signatures.

## Diagnose failures and respect boundaries

| Symptom | Check and next step |
| --- | --- |
| `MissingRule` at startup | Confirm the ID is in the selected registry; with `inventory`, enable the feature and link the registering crate. |
| `UnsupportedInput` or `InputTypeMismatch` | Compare `InputType` in the signature with `ValidationValue` at the call site; text is not converted automatically. |
| `UnknownParameter` or `ParameterAlreadyConsumed` | Read each configured parameter once, call `finish()`, correct the configuration, and bind again. |
| Dependency failure | Check slot names, order, type, and required status; use `Missing` only for an optional absent value. |
| `AdapterContractViolation` | Inspect the custom prepared validator's declared input and dependencies, and ensure an invalid result has drafts. |
| `!complete()` or `is_truncated()` | Check report limits and early exits; treat the result as incomplete. |
| Field name missing from logs | `ValidationPath` formatting hides fields and map positions; render only in an authorized presentation layer. |

`ExecutionError` retains an owned cause only for explicit trusted diagnostics through `trusted_source()`; ordinary `Display`, `Debug`, and `Error::source()` do not reveal it. `ViolationParam` restricts representation, not provenance: a number from rule configuration can be safe while the same type derived from rejected input may leak data. Keep rejected and sensitive values out of codes, parameters, errors, and logs. Use stable rule IDs and violation codes when clients depend on them.

Validation runs synchronously and borrows each value for the call. Prepared validators require `Send + Sync`; the crate does not create threads or require an async runtime. The application owns object traversal, rule scheduling, request handling, storage, and message localization.

## Further reading

- [README](../README.md) · [Design and invariants](design.md) · [API reference](https://docs.rs/qubit-validator)
- [Runnable local-registry example](../examples/local_registry.rs) · [中文用户手册](user_guide.zh_CN.md)