diff --git a/CLAUDE.md b/CLAUDE.md index 9b546d37..e9213f30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), - Operations (D59, docs/DEPLOY.md): `obop-keys` (the key ring, the sealed envelope, `reseal` for rotation), `obop-migrate` (schema version and ordered migration steps), `obop-ops` (backup, restore, verify, `secrets rotate`); `obop-storage` also holds the one PostgreSQL connector with TLS (`obop_storage::tls::PgConnector`, D66: `sslmode` and `sslrootcert` in the URL, rustls, plain when unset; every PostgreSQL connection goes through it) and the cross-process lock, counters, expiring values and compare-and-set that make several server processes on one PostgreSQL database behave as one. CLI: `obop migrate|backup|restore|secrets`, `obop platform upgrade-instances`; `deploy/restore-drill.sh`; `.github/workflows/image.yml` publishes `ghcr.io/spacecorps/obop`. - Operations since D73 (`docs/DEPLOY.md` "Backups" and "Upgrades", `docs/RUNBOOK-RECOVERY.md`): `obop-keys::stream` (the chunked ChaCha20-Poly1305 seal of format 2 backups), `obop-migrate` (the checksummed migration ledger `obop_migration`, drift refusal at start, adoption of older databases, the database id `obop_meta/instance`, stored plans, the token-based DDL classifier `ddl.rs`, `RunOptions::before` where a self-migrating server takes its backup under the migration lock), `obop-ops` (`backup` formats 1 and 2 with a MAC'd manifest and refusal of plain backups where a key is configured, `fsutil` crash-safe private file publishing, `schedule` and `retention`, `sink` (`BackupSink`, `FsSink`), `catalog` (records `obop_backup`, `obop_restore_drill`, `obop_storage_attestation`), `gates` (strict and relaxed migration gates, rehearsal receipts tied to the database id, rollback in one transaction), `drill`, `doctor` for `obop storage check`, `pgadmin` (`ServerInfo`)); `obop-pkg` has the `MigrationGate` port implemented by `backup_cli::ModuleGate`. CLI: `obop migrate status|verify|plan|run|rehearse|rollback`, `obop backup [run|list|prune|verify]`, `obop restore`, `obop drill`, `obop storage check|attest|show` (CLI-only; D73 asks the owner for that exemption); stable exit statuses 3 (`restore_key_required`), 4 (`backup_not_sealed`), 5 (`restore_files_failed`). Settings: `OBOP_BACKUP_DIR|KEY|KEY_PREVIOUS|SCHEDULE|COPY_DIR|COPY_DELETE|KEEP_*|MAX_AGE_DAYS`, `OBOP_HOST_ID`, `OBOP_MIGRATION_GATES`, `OBOP_INSTANCE_BACKUP_DIR` (control plane). - Decimal and datetime kinds (D72): `obop-values` (leaf crate: `Decimal` as i128 units with strict parser, checked arithmetic with a 256-bit division, `Round`; `Instant` in microseconds, RFC 3339 in and canonical UTC out; `Bucket`; the bundled IANA time zone database via `jiff`, `ZoneDb::fixture` for tests; the one source of the examples and patterns the surfaces print). `FieldKind::Decimal{precision,scale,round}` and `Datetime` live in `obop-registry`; every write path calls `kinds::canonicalise` (`obop-commands/src/kinds.rs`) and the stores accept canonical text only; queries use `Key::Dec`; aggregates are exact (`avg` takes `scale` and `round`); filters and `group_by` take `bucket` (day, week, iso_week, month, year) and `zone`; `OBOP_ZONE` (default UTC) is read only by `App::set_zones_from_env`; a changed decimal scale or smaller precision is a breaking package change that needs a `[[migration]] op = "reformat_field"`. Backoffice: `decimals.ts`, `instants.ts`. Not in the backoffice views yet: decimal measures and datetime or iso_week or year dimensions in pivot, graph, kanban, calendar and dashboards (D72 Not delivered). +- Field limits (D90, PROPOSED, in review): `FieldDef.limits` in `obop-registry/src/limits.rs` (`Limits`, `Bound`, `Breach`, the install gate `problems`, and the four constants `DEFAULT_TEXT_MAX` 2,048, `DEFAULT_LONGTEXT_MAX` 100,000, `TEXT_MAX_CAP` 10,000, `LONGTEXT_MAX_CAP` 1,000,000, whose values are the owner's to rule on); manifest keys `max_length`, `min_length`, `min`, `max` (`obop-pkg` `FieldM`, `bound_of`; a package that declares one writes `requires_core = "^0.2.1"`, and `CORE_VERSION` is 0.2.1); characters are Unicode code points, never bytes; the check is `kinds::check_text_limits|check_int_limits|check_decimal_limits` behind `obop-commands/src/engine/crud.rs` (`check_fields_against`, `check_effect_fields`) and, for the public submit, `store::check_value`; the code `limit_violated` (400) is kept through an effect by `errors::wrap` and left off a bulk row and a public submit; a refusal states the length or the value only to a person who typed it, never to an agent or from an action effect; a stored value that an `update` leaves as it is is not re-checked; upgrade diff kind `field_limits` (`obop-pkg/src/migrate.rs`, `Held`); back office `limits.ts` (`codePoints`) and the counter in `fields.tsx`. Not delivered, with the effect maps, the module builder and the writers that bypass the checks: docs/ISSUES.md K43 to K46. - System fields and record history (D75, first slice; nothing is tracked yet, so no record gets a system key and the surfaces differ only by two renames: the module list says `upgraded_at` for `updated_at`, and `connection` no longer has `created_at`): `obop_storage::sysfields` is the planner every backend shares (six system keys, no-op writes stamp nothing, `row_version` compare-and-set through `Op::Patch`, `Op::EraseHistory`), memory, SQLite and PostgreSQL keep the history (PostgreSQL: `obop-storage/src/pg_hist.rs`, the table `obop_record_change` partitioned by month with an append-only guard and three `SECURITY DEFINER` maintenance functions, made on first use and not at start; `Backend::changes` is the commit-safe change feed with cursors bound to the epoch and the cluster, plus `history_stats`, `history_ensure_partition`, `history_drop_partition` and the history in backup and restore; docs/DEPLOY.md "Change history and system fields on PostgreSQL"); the command layer, REST, MCP, the CLI and the UI use none of it yet; the reserved names, `ModelExt.history` and its manifest keys are checked by the install gate of `obop-registry`. - Modules and platform: `obop-pkg` (obop.toml X1 packages: parse, gate, semver and SPDX checks, dependency resolution and install plans, runtime install/uninstall, marketplace; admin commands `module.*`, `module_plan.preview` and `marketplace_listing.*`; D55: one core version `obop_types::CORE_VERSION`, module metadata, versioned dependencies), `obop-platform` (control plane: accounts, instances, provisioners local/sliplane/fake). `packages/*.toml` is the bundled catalog; translations (D52) are `[labels.]` tables in a package document, bundled as `packages/labels//.toml` (de, fr, pl, fi, sv, es; also for the built-in modules), checked by `obop modules check --complete FILE`, templated by `obop modules labels TARGET LANG`. A text field can show the label of an option of a select (`shows_option_of` and `when` in `[[model.field]]`, D52 amendment): `ShowsOption` in `obop-registry` `rules.rs`, the gate `expr::check_display_subset`, `shows_option_of` and `shows_option_when` in `/api/meta`; the browser evaluates the condition (`displayCondition.ts`, `shownOption.ts` in the backoffice), and `displayCondition.fixtures.json` is read by both it and `obop-commands/src/shows_option_tests.rs`. See `docs/DEPLOY.md`; Sliplane project `obop` (`project_2x9d8q51m9lt`, account `spacecorps`) exists and is empty. - Updates (D63, `docs/DEPLOY.md` "Updating from inside"): every package declares `requires_core` and a range per dependency; `module.upgrade|upgrade_all|rollback` (atomic batch, `[[migration]]` data steps in `obop-pkg::migrate`, D59 lock, audited, agents propose); `obop-update` (release feed, core compatibility of installed modules, `instance_update.check|status|start|rollback`, `obop update ...`) asks the control plane (`obop-platform::update`: per-instance update token, `GET /api/releases`, rollback to the previous image when the new one does not start). diff --git a/Cargo.lock b/Cargo.lock index d4240593..66c1d942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,7 +131,7 @@ dependencies = [ [[package]] name = "base" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-masking", @@ -299,7 +299,7 @@ checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "connectors" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base", "crm", @@ -339,7 +339,7 @@ dependencies = [ [[package]] name = "crm" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-registry", @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "invoicing" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-registry", @@ -1150,7 +1150,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mileage" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-registry", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "obop" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "base", @@ -1293,7 +1293,7 @@ dependencies = [ [[package]] name = "obop-auth" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "getrandom 0.4.3", @@ -1309,7 +1309,7 @@ dependencies = [ [[package]] name = "obop-commands" -version = "0.2.0" +version = "0.2.1" dependencies = [ "icu_collator", "icu_locale", @@ -1328,7 +1328,7 @@ dependencies = [ [[package]] name = "obop-community" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ammonia", "form_urlencoded", @@ -1345,7 +1345,7 @@ dependencies = [ [[package]] name = "obop-connectors" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base64 0.22.1", "chacha20poly1305", @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "obop-http" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "getrandom 0.4.3", @@ -1394,7 +1394,7 @@ dependencies = [ [[package]] name = "obop-keys" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chacha20poly1305", "getrandom 0.4.3", @@ -1407,7 +1407,7 @@ dependencies = [ [[package]] name = "obop-masking" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-types", "serde_json", @@ -1415,24 +1415,25 @@ dependencies = [ [[package]] name = "obop-mcp" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-registry", "obop-types", + "obop-values", "serde_json", ] [[package]] name = "obop-media" -version = "0.2.0" +version = "0.2.1" dependencies = [ "image", ] [[package]] name = "obop-migrate" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-keys", "obop-storage", @@ -1444,7 +1445,7 @@ dependencies = [ [[package]] name = "obop-oauth" -version = "0.2.0" +version = "0.2.1" dependencies = [ "getrandom 0.4.3", "obop-auth", @@ -1457,7 +1458,7 @@ dependencies = [ [[package]] name = "obop-ops" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base64 0.22.1", "flate2", @@ -1474,7 +1475,7 @@ dependencies = [ [[package]] name = "obop-payments" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base", "getrandom 0.4.3", @@ -1492,7 +1493,7 @@ dependencies = [ [[package]] name = "obop-pkg" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-migrate", @@ -1508,7 +1509,7 @@ dependencies = [ [[package]] name = "obop-platform" -version = "0.2.0" +version = "0.2.1" dependencies = [ "getrandom 0.4.3", "obop-auth", @@ -1527,7 +1528,7 @@ dependencies = [ [[package]] name = "obop-registry" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-types", "obop-values", @@ -1537,7 +1538,7 @@ dependencies = [ [[package]] name = "obop-site" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ammonia", "base", @@ -1555,7 +1556,7 @@ dependencies = [ [[package]] name = "obop-storage" -version = "0.2.0" +version = "0.2.1" dependencies = [ "postgres", "rusqlite", @@ -1569,11 +1570,11 @@ dependencies = [ [[package]] name = "obop-types" -version = "0.2.0" +version = "0.2.1" [[package]] name = "obop-update" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-keys", @@ -1588,7 +1589,7 @@ dependencies = [ [[package]] name = "obop-values" -version = "0.2.0" +version = "0.2.1" dependencies = [ "jiff", "jiff-tzdb", @@ -1831,7 +1832,7 @@ dependencies = [ [[package]] name = "product" -version = "0.2.0" +version = "0.2.1" dependencies = [ "obop-commands", "obop-registry", @@ -2010,7 +2011,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sales" -version = "0.2.0" +version = "0.2.1" dependencies = [ "invoicing", "obop-commands", diff --git a/Cargo.toml b/Cargo.toml index da57b454..06199536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["crates/kernel/*", "crates/modules/*", "crates/obop"] edition = "2024" rust-version = "1.99" publish = false -version = "0.2.0" +version = "0.2.1" license = "Apache-2.0" repository = "https://github.com/SpaceCorps/obop" diff --git a/crates/kernel/obop-commands/src/engine/bulk.rs b/crates/kernel/obop-commands/src/engine/bulk.rs index 49166ac8..b3276493 100644 --- a/crates/kernel/obop-commands/src/engine/bulk.rs +++ b/crates/kernel/obop-commands/src/engine/bulk.rs @@ -445,7 +445,8 @@ pub(crate) fn bulk_create(eng: &Engine, def: &Arc, input: &Value) -> R ids.push(None); failed += 1; if errors.len() < MAX_LISTED_ERRORS { - errors.push((r.row, r.line, e)); + // The list names the row; a refusal with a code (D90) is listed by its text. + errors.push((r.row, r.line, crate::errors::plain(&e).to_string())); } } } diff --git a/crates/kernel/obop-commands/src/engine/crud.rs b/crates/kernel/obop-commands/src/engine/crud.rs index a93d0113..8eee5a33 100644 --- a/crates/kernel/obop-commands/src/engine/crud.rs +++ b/crates/kernel/obop-commands/src/engine/crud.rs @@ -12,7 +12,7 @@ use crate::guards; use crate::kinds::{self, Path}; use crate::query::Query; use crate::store::{declared_fields, id_of}; -use obop_registry::{FieldKind, ModelDef, is_valid_date}; +use obop_registry::{FieldDef, FieldKind, ModelDef, is_valid_date}; use obop_storage::Fields; use serde_json::Value as J; use std::collections::BTreeMap; @@ -57,19 +57,45 @@ pub(crate) fn own_default(def: &ModelDef, f: &mut Fields, actor: &Actor) { } } +/// How one value is held to the limits of its field (D90). +#[derive(Clone, Copy)] +struct Held { + /// The value is the one already stored (an update that sends the record back as it is). It is + /// checked for its kind like any other, but not against the limits of the field: a value is + /// held to a limit when it is written, not for being there. + unchanged: bool, + /// A refusal may state how long or how large the value is. Not when the caller cannot know + /// that (an agent holding a masked token, a value an action derived from a record). + say_value: bool, +} + fn check_json( reads: &dyn Reads, gate: &Gate<'_>, lookup: &dyn Fn(&str) -> Option>, model: &str, - name: &str, - kind: &FieldKind, + f: &FieldDef, v: &J, + held: Held, ) -> R<()> { + let Held { + unchanged, + say_value, + } = held; + let (name, kind) = (f.name, &f.kind); let wrong = || Err(format!("{model}.{name} must be {}", kind.describe())); match (kind, v) { - (FieldKind::Text | FieldKind::LongText, J::String(_)) => Ok(()), - (FieldKind::Number | FieldKind::Money, J::Number(n)) if n.is_i64() => Ok(()), + (FieldKind::Text | FieldKind::LongText, J::String(s)) => { + if unchanged { + Ok(()) + } else { + kinds::check_text_limits(model, f, s, say_value) + } + } + (FieldKind::Number | FieldKind::Money, J::Number(n)) if n.is_i64() => match n.as_i64() { + Some(i) if !unchanged => kinds::check_int_limits(model, f, i, say_value), + _ => Ok(()), + }, (FieldKind::Bool, J::Bool(_)) => Ok(()), // A stored image or file (D60): a well-formed id of an existing blob of the right sort. // Who may use which blob is the reference guard's business. @@ -95,7 +121,11 @@ fn check_json( // Decimals and instants are stored as canonical text (D72); every write path // canonicalises first, so anything else here is refused. (FieldKind::Decimal { .. } | FieldKind::Datetime, J::String(t)) => { - kinds::check_canonical(model, name, kind, t) + kinds::check_canonical(model, name, kind, t)?; + if matches!(kind, FieldKind::Decimal { .. }) && !unchanged { + kinds::check_decimal_limits(model, f, t, say_value)?; + } + Ok(()) } (FieldKind::Select(o), J::String(t)) if o.contains(&t.as_str()) => Ok(()), (FieldKind::Ref(to), J::String(id)) => { @@ -119,8 +149,8 @@ fn check_json( } /// Validate the fields of a create (`creating`: every required field must be present) or of an -/// update against their kinds (D47), required flags and refs. A ref must name a record the caller -/// can see. +/// update against their kinds (D47), required flags, limits (D90) and refs. A ref must name a +/// record the caller can see. pub(crate) fn check_fields( reads: &dyn Reads, lookup: &dyn Fn(&str) -> Option>, @@ -129,6 +159,79 @@ pub(crate) fn check_fields( fields: &Fields, creating: bool, ) -> R<()> { + check_fields_against(reads, lookup, actor, def, fields, creating, None) +} + +/// As [`check_fields`] for an update of a record that is `stored`: a value that equals the stored +/// one is not held to the limits of its field again (D90 Decision 6), so a record above a limit +/// that was lowered later can still be saved with its other fields changed, and a form that sends +/// the whole record back works. Every other check (kind, refs, required) still runs on it. +/// +/// A refusal of a limit states the length or the number the caller wrote, unless the caller is an +/// agent: its tokens were restored to the real values before this runs, so the count would tell +/// it about data it may not read (D90 Decision 5). +pub(crate) fn check_fields_against( + reads: &dyn Reads, + lookup: &dyn Fn(&str) -> Option>, + actor: &Actor, + def: &ModelDef, + fields: &Fields, + creating: bool, + stored: Option<&Fields>, +) -> R<()> { + let how = Writing { + creating, + stored, + say_value: !actor.agent, + }; + check_with(reads, lookup, actor, def, fields, how) +} + +/// As [`check_fields_against`] for the values an action effect writes (`create`, `set`, `adjust`): +/// they are the action's, copied from the record or from the author's constants, and the caller +/// may not be allowed to read the field they came from (`$self.salary`), so a refusal of a limit +/// never states their length or number, whoever the caller is (D90 Decision 5). +pub(crate) fn check_effect_fields( + reads: &dyn Reads, + lookup: &dyn Fn(&str) -> Option>, + actor: &Actor, + def: &ModelDef, + fields: &Fields, + creating: bool, + stored: Option<&Fields>, +) -> R<()> { + let how = Writing { + creating, + stored, + say_value: false, + }; + check_with(reads, lookup, actor, def, fields, how) +} + +/// What kind of write `check_with` is checking. +#[derive(Clone, Copy)] +struct Writing<'a> { + /// A create: every required field must be present. + creating: bool, + /// The record as it is stored, for an update. + stored: Option<&'a Fields>, + /// A refusal of a limit may state the length or number written. + say_value: bool, +} + +fn check_with( + reads: &dyn Reads, + lookup: &dyn Fn(&str) -> Option>, + actor: &Actor, + def: &ModelDef, + fields: &Fields, + how: Writing<'_>, +) -> R<()> { + let Writing { + creating, + stored, + say_value, + } = how; let gate = Gate::new(reads, lookup, actor); for f in &def.fields { if f.computed.is_some() { @@ -146,7 +249,13 @@ pub(crate) fn check_fields( )); } Some(J::Null) => {} - Some(v) => check_json(reads, &gate, lookup, def.name, f.name, &f.kind, v)?, + Some(v) => { + let held = Held { + unchanged: stored.is_some_and(|s| s.get(f.name) == Some(v)), + say_value, + }; + check_json(reads, &gate, lookup, def.name, f, v, held)? + } } } Ok(()) @@ -333,7 +442,7 @@ pub(crate) fn update(eng: &Engine, def: &Arc, input: &Value) -> R { if obop_storage::fault::Fault::is_transient(&m) { return m; } - format!( - "{}.{}: effect {} ({}) failed, nothing was changed: {m}", - def.name, - cmd.name, - i + 1, - e.kind_name() + errors::wrap( + &format!( + "{}.{}: effect {} ({}) failed, nothing was changed", + def.name, + cmd.name, + i + 1, + e.kind_name() + ), + &m, ) })?; } @@ -490,7 +494,10 @@ impl Runner<'_> { for tid in ids { self.action(unit, &target, next, &tid, depth + 1) .map_err(|m| { - format!("{}.{action} on {}: {m}", target.name, "the related record") + errors::wrap( + &format!("{}.{action} on the related record", target.name), + &m, + ) })?; } } @@ -568,7 +575,7 @@ impl Runner<'_> { default_governed(def, &mut f); own_default(def, &mut f, self.actor); let look = |n: &str| self.look(n); - check_fields(&*unit, &look, self.actor, def, &f, true)?; + check_effect_fields(&*unit, &look, self.actor, def, &f, true, None)?; seq::assign(unit, def, &mut f, None, self.now)?; if let Some(why) = creator_blocked(&Gate::new(&*unit, &look, self.actor), def, &f) { return Err(why); @@ -597,7 +604,7 @@ impl Runner<'_> { .load(def, id) .map_err(|_| self.unavailable(def))?; kinds::canonicalise(def, &mut patch, Path::Write)?; - check_fields(&*unit, &look, self.actor, def, &patch, false)?; + check_effect_fields(&*unit, &look, self.actor, def, &patch, false, Some(&cur))?; let mut after = cur; after.extend(patch.clone()); if let Some(why) = creator_blocked(&Gate::new(&*unit, &look, self.actor), def, &after) { diff --git a/crates/kernel/obop-commands/src/errors.rs b/crates/kernel/obop-commands/src/errors.rs index be90e808..0a4adc63 100644 --- a/crates/kernel/obop-commands/src/errors.rs +++ b/crates/kernel/obop-commands/src/errors.rs @@ -97,6 +97,9 @@ pub mod code { pub const USE_SEARCH: &str = "use_search"; /// D56: an assertion of the action did not hold (REST names it; see the module comment). pub const ASSERTION_FAILED: &str = "assertion_failed"; + /// D90: a value is outside a limit of its field (`max_length`, `min_length`, `min`, + /// `max`, or the default length of a text field). + pub const LIMIT_VIOLATED: &str = "limit_violated"; /// Step 2: the caller is known and may not do this. pub const DENIED: &str = "denied"; /// Step 2: the caller must confirm their identity again. @@ -232,6 +235,13 @@ pub const TABLE: &[Row] = &[ origin: "D56", hint: "A condition of the action does not hold. Change the record as the message says, then retry.", }, + Row { + code: code::LIMIT_VIOLATED, + status: 400, + retry_after: None, + origin: "D90", + hint: "A value is outside the limits of its field (max_length, min_length, min and max in GET /api/meta; a text field with no max_length has the default of its kind). Nothing was changed. Shorten the text or change the number so that it fits, then retry.", + }, Row { code: code::DENIED, status: 403, @@ -294,6 +304,21 @@ pub fn coded(code: &'static str, message: &str) -> String { format!("{code}: {message}") } +/// `context: message`, for an engine that wraps the refusal of something it started (an effect of +/// an action, the action a `set_state` effect starts) with where it happened. A refusal that +/// carries the code `limit_violated` (D90) keeps its code first, so that the wrapped answer is +/// still that code with its hint on every surface: `limit_violated: context: message`. Every other +/// refusal is wrapped as it always was (an assertion is recognised by the text it noted, see +/// `locale::assertion_behind`). +pub(crate) fn wrap(context: &str, m: &str) -> String { + match split_code(m) { + Some((r, rest)) if r.code == code::LIMIT_VIOLATED => { + coded(code::LIMIT_VIOLATED, &format!("{context}: {rest}")) + } + _ => format!("{context}: {m}"), + } +} + /// What kind of answer the call earned, apart from its status. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Kind { @@ -699,6 +724,13 @@ fn split_code(m: &str) -> Option<(&'static Row, &str)> { .map(|r| (r, rest)) } +/// The message of a handler string without its leading code (`code: message`), for a place that +/// lists refusals one by one and names the row itself (the rows of `bulk_create`). A text that +/// does not begin with a code of the table is returned as it is. +pub fn plain(m: &str) -> &str { + split_code(m).map_or(m, |(_, rest)| rest) +} + /// The `origin` of the three storage rows. const STORAGE_ORIGIN: &str = "D74"; @@ -889,6 +921,7 @@ mod tests { ("limit_required", 400), ("use_search", 400), ("assertion_failed", 400), + ("limit_violated", 400), ("denied", 403), ("reauth_required", 400), ("incomplete", 409), diff --git a/crates/kernel/obop-commands/src/guards.rs b/crates/kernel/obop-commands/src/guards.rs index f043b455..ad413e4c 100644 --- a/crates/kernel/obop-commands/src/guards.rs +++ b/crates/kernel/obop-commands/src/guards.rs @@ -10,7 +10,7 @@ //! - `delete`: refused while another record refers to the one being deleted. use crate::engine::access::Gate; -use crate::engine::crud::check_fields; +use crate::engine::crud::check_fields_against; use crate::engine::effects::{lookup_fn, precheck, reachable, required_missing}; use crate::engine::unit::{Plain, Reads}; use crate::engine::{Actor, Engine}; @@ -206,7 +206,7 @@ fn update_guard(def: &ModelDef, eng: &Engine) -> Option { .map(|(k, v)| (k, v.to_json())) .collect(); crate::kinds::canonicalise(&def, &mut given, crate::kinds::Path::Write)?; - check_fields(&reads, &look, &actor, &def, &given, false)?; + check_fields_against(&reads, &look, &actor, &def, &given, false, Some(¤t))?; } Ok(verdict) })) diff --git a/crates/kernel/obop-commands/src/kinds.rs b/crates/kernel/obop-commands/src/kinds.rs index 744d001d..a5493622 100644 --- a/crates/kernel/obop-commands/src/kinds.rs +++ b/crates/kernel/obop-commands/src/kinds.rs @@ -10,7 +10,8 @@ //! numbers and instants they stand for wherever strings are compared. use crate::Value; -use obop_registry::{FieldKind, ModelDef}; +use crate::errors::{self, code}; +use obop_registry::{Breach, FieldDef, FieldKind, ModelDef}; use obop_storage::Fields; use obop_values::{Decimal, Instant}; use serde_json::Value as J; @@ -116,6 +117,59 @@ pub(crate) fn canonicalise_values( Ok(()) } +/// The refusal of a value that breaks a limit of its field (D90): `limit_violated: `. +/// `say_value`: the message may state how long or how large the value was. It may not when the +/// caller cannot know that (an agent holding a masked token, where the handler sees the restored +/// real value; a value an action copied from a field the caller cannot read): the length or the +/// number would disclose data the caller may not read, so the message states the requirement only. +fn breach(model: &str, f: &FieldDef, b: Breach, value: &str, say_value: bool) -> String { + let message = if say_value { + b.message(model, f.name, value) + } else { + b.message_without_value(model, f.name) + }; + errors::coded(code::LIMIT_VIOLATED, &message) +} + +/// A written text against the length limits of its field, the default included (D90). +pub(crate) fn check_text_limits( + model: &str, + f: &FieldDef, + s: &str, + say_value: bool, +) -> Result<(), String> { + f.check_text(s) + .map_err(|b| breach(model, f, b, "", say_value)) +} + +/// A written whole number (`number` or `money`) against `min` and `max` of its field (D90). +pub(crate) fn check_int_limits( + model: &str, + f: &FieldDef, + n: i64, + say_value: bool, +) -> Result<(), String> { + f.check_int(n) + .map_err(|b| breach(model, f, b, &n.to_string(), say_value)) +} + +/// A written decimal, in its canonical text, against `min` and `max` of its field (D90). The +/// comparison is exact, by `Decimal`, and never goes through a float. +pub(crate) fn check_decimal_limits( + model: &str, + f: &FieldDef, + canonical: &str, + say_value: bool, +) -> Result<(), String> { + if f.lower_bound().is_none() && f.upper_bound().is_none() { + return Ok(()); + } + let d = Decimal::parse(canonical) + .map_err(|e| wrong(model, f.name, &f.kind, Some(&e.to_string())))?; + f.check_decimal(d) + .map_err(|b| breach(model, f, b, canonical, say_value)) +} + /// A stored or supplied value must already be in canonical form (see the module notes). pub(crate) fn check_canonical( model: &str, diff --git a/crates/kernel/obop-commands/src/lib.rs b/crates/kernel/obop-commands/src/lib.rs index 2ddd2173..8821575e 100644 --- a/crates/kernel/obop-commands/src/lib.rs +++ b/crates/kernel/obop-commands/src/lib.rs @@ -11,6 +11,7 @@ use shared::{Pending, Plans}; use obop_masking::mode_for; pub use obop_masking::{PrincipalKind, RestoreError, Vault}; +pub use obop_registry::Bound; use obop_registry::labels::Texts; use obop_registry::{FieldDef, Module, Transition, Translations}; use obop_types::MaskMode; @@ -365,6 +366,19 @@ pub struct FieldInfo { pub shows_option_of: Option, /// The condition on the same record under which the label shows; the browser evaluates it. pub shows_option_when: Option, + /// `text` and `longtext` (D90): the most characters a written value has, the default of the + /// kind when the field declares none (`None` for a computed or numbered field and every other + /// kind), and the fewest, when the field declares a minimum. + pub max_length: Option, + pub min_length: Option, + /// `number`, `money` and `decimal` (D90): the declared bounds, inclusive. A `decimal` bound is + /// an exact decimal at the scale of the field. + pub min: Option, + pub max: Option, + /// The limits the field declares as a parenthesised sentence for a description (OpenAPI, MCP), + /// `(at most 2000 characters)` or `(from 0 to 100)`; empty when it declares none (the default + /// of its kind is in `max_length`, not in the description). + pub limits_note: String, } impl FieldInfo { @@ -593,6 +607,11 @@ impl Dispatcher { .as_ref() .and_then(|s| s.when.as_ref()) .map(|w| w.source.clone()), + max_length: f.effective_max_length(), + min_length: f.effective_min_length(), + min: f.lower_bound(), + max: f.upper_bound(), + limits_note: f.limits_note(), } }) .collect(), @@ -1733,6 +1752,8 @@ mod keyexpr_parity; #[cfg(test)] mod kinds_tests; #[cfg(test)] +mod limits_tests; +#[cfg(test)] mod shows_option_tests; #[cfg(test)] mod sysfields_tests; diff --git a/crates/kernel/obop-commands/src/limits_tests.rs b/crates/kernel/obop-commands/src/limits_tests.rs new file mode 100644 index 00000000..b23685f5 --- /dev/null +++ b/crates/kernel/obop-commands/src/limits_tests.rs @@ -0,0 +1,1179 @@ +//! Field limits (D90) through the dispatcher: `max_length` and `min_length` of text and longtext +//! (in characters, not bytes), the default length of a text field, `min` and `max` of number, +//! money and decimal (exact), on create, update, `bulk_create` (the row is named), the effects of +//! an action, and the rule for a record stored above a limit that was lowered later. Every test +//! runs on the memory and SQLite backends, and on PostgreSQL when `OBOP_TEST_POSTGRES_URL` is set. + +use crate::{CallError, Dispatcher, Principal, Response, Store, Surface, Value, Vault, errors}; +use obop_registry::{ + Bound, CommandDef, DEFAULT_LONGTEXT_MAX, DEFAULT_TEXT_MAX, Effect, FieldDef as F, FieldKind, + Limits, ModelDef, Module, intern, +}; +use obop_types::DataClass; +use obop_values::Decimal; +use std::sync::Arc; + +const U: &str = "lim.user"; +const HR: &str = "lim.hr"; + +fn m(pairs: Vec<(&str, Value)>) -> Value { + Value::Map(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) +} +fn t(s: &str) -> Value { + Value::text(s) +} +fn get<'a>(v: &'a Value, k: &str) -> &'a Value { + match v { + Value::Map(m) => m.get(k).unwrap_or(&Value::Null), + other => panic!("not a map: {other:?}"), + } +} +fn text(v: &Value) -> String { + match v { + Value::Text(s) => s.clone(), + other => panic!("not text: {other:?}"), + } +} +fn list(v: &Value) -> &[Value] { + match v { + Value::List(l) => l, + other => panic!("not a list: {other:?}"), + } +} +fn dec(s: &str, scale: u8) -> Bound { + Bound::Dec(Decimal::parse_for(s, 12, scale, None).unwrap()) +} + +struct Rig { + d: Arc, + p: String, + who: Principal, + store: Store, +} + +fn module(p: &str) -> Module { + let n = |s: &str| intern(&format!("{p}_{s}")); + let f = |name: &'static str| F::new(name, "d", DataClass::None, false, U); + let model = |name: &'static str, fields: Vec, commands: Vec| ModelDef { + name, + description: "d", + read_group: U, + write_group: U, + access: None, + fields, + commands, + ext: Default::default(), + }; + Module { + id: p.into(), + name: "L".into(), + models: vec![ + model( + n("doc"), + vec![ + // Declared: 2 to 10 characters. + f("title").with_limits(Limits { + min_length: Some(2), + max_length: Some(10), + ..Default::default() + }), + // Nothing declared: the default of the kind applies. + f("plain"), + f("body").kind(FieldKind::LongText), + // A longtext with a much lower limit than its default. + f("tiny").kind(FieldKind::LongText).with_limits(Limits { + max_length: Some(5), + ..Default::default() + }), + f("qty").kind(FieldKind::Number).with_limits(Limits { + min: Some(Bound::Int(0)), + max: Some(Bound::Int(100)), + ..Default::default() + }), + f("price").kind(FieldKind::Money).with_limits(Limits { + min: Some(Bound::Int(0)), + max: Some(Bound::Int(100_000)), + ..Default::default() + }), + f("rate").decimal(12, 7).with_limits(Limits { + min: Some(dec("0.1000000", 7)), + max: Some(dec("0.9000000", 7)), + ..Default::default() + }), + // Only a lower bound, only an upper bound. + f("floor").kind(FieldKind::Number).with_limits(Limits { + min: Some(Bound::Int(-5)), + ..Default::default() + }), + f("ceiling").kind(FieldKind::Number).with_limits(Limits { + max: Some(Bound::Int(5)), + ..Default::default() + }), + f("state").select(&["new", "done"]), + // A person's name: an agent sees it only as a masked token. + F::new("person", "d", DataClass::PublicDirectory, true, U), + ], + vec![ + // An effect that writes the title of the record into another model. + CommandDef::new("finish", "Finish", U) + .transition("state", Some("new"), "done") + .effect(Effect::create( + &format!("{p}_log"), + &[("short", "$self.plain"), ("title", "$self.title")], + )), + ], + ), + model( + n("log"), + vec![ + f("title"), + // At most 3 characters. + f("short").with_limits(Limits { + max_length: Some(3), + ..Default::default() + }), + // At most 100. + f("cap").kind(FieldKind::Money).with_limits(Limits { + max: Some(Bound::Int(100)), + ..Default::default() + }), + // At most 0.90. + f("ratio").decimal(8, 2).with_limits(Limits { + max: Some(dec("0.90", 2)), + ..Default::default() + }), + ], + vec![], + ), + model( + n("payroll"), + vec![ + // A field of the HR group: nobody else reads it. + F::new("salary", "d", DataClass::None, false, HR).kind(FieldKind::Money), + F::new("bonus", "d", DataClass::None, false, HR).decimal(8, 2), + f("state").select(&["new", "paid"]), + ], + vec![ + // The same with a decimal: the bonus into a field that holds at most 0.90. + CommandDef::new("grant", "Grant", U) + .transition("state", Some("new"), "paid") + .effect(Effect::create( + &format!("{p}_log"), + &[("ratio", "$self.bonus")], + )), + // Copies the salary, which the caller may not read, into a field that holds + // at most 100. + CommandDef::new("pay", "Pay", U) + .transition("state", Some("new"), "paid") + .effect(Effect::create( + &format!("{p}_log"), + &[("cap", "$self.salary")], + )), + ], + ), + model( + n("batch"), + vec![ + f("counter_id").reference(&format!("{p}_counter")), + f("state").select(&["open", "run"]), + ], + vec![ + // Starts `counter.apply`, whose adjust effect meets the bound of the document. + CommandDef::new("run", "Run", U) + .transition("state", Some("open"), "run") + .effect(Effect::set_state_ref( + &format!("{p}_counter"), + "counter_id", + "apply", + )), + ], + ), + model( + n("counter"), + vec![ + f("doc_id").reference(&format!("{p}_doc")), + f("by").kind(FieldKind::Number), + f("state").select(&["open", "applied"]), + ], + vec![ + // Adds `by` to the quantity of the document, which stops at 100. + CommandDef::new("apply", "Apply", U) + .transition("state", Some("open"), "applied") + .effect(Effect::adjust_ref( + &format!("{p}_doc"), + "doc_id", + "qty", + false, + "$self.by", + )), + ], + ), + ], + network_destinations: vec![], + description: String::new(), + translations: Default::default(), + meta: Default::default(), + } +} + +fn each(f: impl Fn(&Rig)) { + let mut urls = vec!["memory:".to_string(), "sqlite::memory:".to_string()]; + if let Ok(u) = std::env::var("OBOP_TEST_POSTGRES_URL") { + urls.push(u); + } + for url in urls { + let p = format!("l{}", &uuid::Uuid::now_v7().simple().to_string()[22..]); + let store = Store::open(&url).unwrap(); + let d = Arc::new(Dispatcher::new()); + let module = module(&p); + let v = obop_registry::check_module(&module); + assert!(v.is_empty(), "{v:?}"); + d.register_crud(&module, &store); + for mo in &module.models { + for c in &mo.commands { + d.register_transition(&module, mo.name, c.name, &store); + } + } + let who = Principal::user(&format!("u-{p}"), &[U]); + f(&Rig { d, p, who, store }); + } +} + +impl Rig { + fn cmd(&self, model: &str, c: &str) -> String { + format!("{}_{model}.{c}", self.p) + } + fn call(&self, model: &str, c: &str, input: Value) -> Result { + self.d.call( + Surface::Rest, + &self.who, + &mut Vault::new(), + &self.cmd(model, c), + &input, + ) + } + /// A person who holds the HR group (reads and writes the salary). + fn hr(&self) -> Principal { + Principal::user(&format!("hr-{}", self.p), &[U, HR]) + } + /// An agent acting for a person with the ordinary group; it sees personal data as tokens. + fn bot(&self) -> Principal { + Principal::agent(&format!("bot-{}", self.p), &[U], &[U]) + } + fn call_as( + &self, + who: &Principal, + surface: Surface, + vault: &mut Vault, + model: &str, + c: &str, + input: Value, + ) -> Result { + self.d + .call(surface, who, vault, &self.cmd(model, c), &input) + } + /// The handler error of a call as `who` over `surface` with `vault`. + fn err_as( + &self, + who: &Principal, + surface: Surface, + vault: &mut Vault, + model: &str, + c: &str, + input: Value, + ) -> String { + match self.call_as(who, surface, vault, model, c, input) { + Err(CallError::Handler(m)) => m, + other => panic!("{model}.{c}: expected a handler error, got {other:?}"), + } + } + fn ok(&self, model: &str, c: &str, input: Value) -> Value { + match self.call(model, c, input) { + Ok(r) => r.value, + Err(e) => panic!("{model}.{c} failed: {e:?}"), + } + } + fn err(&self, model: &str, c: &str, input: Value) -> String { + match self.call(model, c, input) { + Err(CallError::Handler(m)) => m, + other => panic!("{model}.{c}: expected a handler error, got {other:?}"), + } + } + fn create(&self, model: &str, pairs: Vec<(&str, Value)>) -> String { + text(get(&self.ok(model, "create", m(pairs)), "id")) + } + fn refused(&self, model: &str, pairs: Vec<(&str, Value)>) -> String { + self.err(model, "create", m(pairs)) + } + fn update(&self, id: &str, pairs: Vec<(&str, Value)>) -> Result { + let mut all = vec![("id", t(id))]; + all.extend(pairs); + self.call("doc", "update", m(all)) + } + fn read(&self, model: &str, id: &str) -> Value { + self.ok(model, "get", m(vec![("id", t(id))])) + } + fn count(&self, model: &str) -> usize { + self.store.count(&format!("{}_{model}", self.p)) + } + /// Write a field of a stored record behind the commands' back, as it was before a limit was + /// lowered, or by a package version that had none. + fn corrupt(&self, model: &str, id: &str, field: &str, value: serde_json::Value) { + let patch = [(field.to_string(), value)].into_iter().collect(); + assert!( + self.store + .backend() + .update(&format!("{}_{model}", self.p), id, &patch) + .unwrap() + ); + } +} + +/// `limit_violated: . `: the code, the field and the numbers. +fn assert_limit(msg: &str, field: &str, what: &str) { + assert!( + msg.starts_with("limit_violated: "), + "not a limit_violated refusal: {msg}" + ); + assert!( + msg.contains(&format!(".{field} must be {what}")), + "wanted `.{field} must be {what}` in: {msg}" + ); +} + +#[test] +fn the_default_lengths_are_the_documented_constants() { + assert_eq!(DEFAULT_TEXT_MAX, 2_048); + assert_eq!(DEFAULT_LONGTEXT_MAX, 100_000); +} + +#[test] +fn a_text_is_counted_in_characters_not_bytes() { + each(|r| { + // Ten characters of two bytes each, and ten of four bytes: both fit a limit of 10. + r.create("doc", vec![("title", t(&"å".repeat(10)))]); + r.create("doc", vec![("title", t(&"\u{1F600}".repeat(10)))]); + // Eleven do not, however short they are in bytes for ASCII. + for (s, n) in [("å", 11), ("\u{1F600}", 11), ("a", 11)] { + let e = r.refused("doc", vec![("title", t(&s.repeat(n)))]); + assert_limit(&e, "title", &format!("at most 10 characters; this has {n}")); + } + // A combining sequence is several characters: e + U+0301 is two. + let e = r.refused("doc", vec![("title", t(&"e\u{301}".repeat(6)))]); + assert_limit(&e, "title", "at most 10 characters; this has 12"); + assert_eq!(r.count("doc"), 2); + }); +} + +#[test] +fn the_limit_is_inclusive_at_both_ends_and_a_message_names_the_field_and_the_numbers() { + each(|r| { + for ok in ["ab", "abcdefghij"] { + r.create("doc", vec![("title", t(ok))]); + } + let e = r.refused("doc", vec![("title", t("abcdefghijk"))]); + assert_limit(&e, "title", "at most 10 characters; this has 11"); + assert!(e.contains(&format!("{}_doc.title", r.p)), "{e}"); + let e = r.refused("doc", vec![("title", t("a"))]); + assert_limit(&e, "title", "at least 2 characters; this has 1"); + // An empty string is a value, and too short; an absent field is not checked. + let e = r.refused("doc", vec![("title", t(""))]); + assert_limit(&e, "title", "at least 2 characters; this has 0"); + r.create("doc", vec![("plain", t("no title at all"))]); + // The singular reads as a singular. + assert_eq!(r.count("doc"), 3); + }); +} + +#[test] +fn whitespace_counts_as_characters_and_nothing_is_trimmed() { + each(|r| { + // Two spaces satisfy min_length 2: the value is counted as sent (D90 says so plainly). + let id = r.create("doc", vec![("title", t(" "))]); + assert_eq!(text(get(&r.read("doc", &id), "title")), " "); + // Ten characters with a trailing space fit; eleven do not. + r.create("doc", vec![("title", t("abcdefghi "))]); + let e = r.refused("doc", vec![("title", t("abcdefghi "))]); + assert_limit(&e, "title", "at most 10 characters; this has 11"); + }); +} + +#[test] +fn a_text_field_without_a_declared_limit_has_the_default() { + each(|r| { + r.create( + "doc", + vec![("plain", t(&"x".repeat(DEFAULT_TEXT_MAX as usize)))], + ); + let e = r.refused( + "doc", + vec![("plain", t(&"x".repeat(DEFAULT_TEXT_MAX as usize + 1)))], + ); + assert_limit( + &e, + "plain", + &format!( + "at most {DEFAULT_TEXT_MAX} characters; this has {}", + DEFAULT_TEXT_MAX + 1 + ), + ); + // 2,048 characters of four bytes are 8 KiB and still fit: the count is of characters. + r.create( + "doc", + vec![("plain", t(&"\u{1F600}".repeat(DEFAULT_TEXT_MAX as usize)))], + ); + assert_eq!(r.count("doc"), 2); + }); +} + +#[test] +fn a_longtext_field_has_its_own_larger_default_and_a_declared_limit_can_be_lower() { + each(|r| { + r.create( + "doc", + vec![("body", t(&"x".repeat(DEFAULT_LONGTEXT_MAX as usize)))], + ); + let e = r.refused( + "doc", + vec![("body", t(&"x".repeat(DEFAULT_LONGTEXT_MAX as usize + 1)))], + ); + assert_limit( + &e, + "body", + &format!( + "at most {DEFAULT_LONGTEXT_MAX} characters; this has {}", + DEFAULT_LONGTEXT_MAX + 1 + ), + ); + r.create("doc", vec![("tiny", t("12345"))]); + let e = r.refused("doc", vec![("tiny", t("123456"))]); + assert_limit(&e, "tiny", "at most 5 characters; this has 6"); + assert_eq!(r.count("doc"), 2); + }); +} + +#[test] +fn number_money_and_decimal_hold_their_inclusive_bounds() { + each(|r| { + for (field, ok, below, above) in [ + ("qty", [0i64, 100], -1i64, 101i64), + ("price", [0, 100_000], -1, 100_001), + ] { + for v in ok { + r.create("doc", vec![(field, Value::Int(v))]); + } + let (lo, hi) = (ok[0], ok[1]); + let e = r.refused("doc", vec![(field, Value::Int(below))]); + assert_limit(&e, field, &format!("at least {lo}; this is {below}")); + let e = r.refused("doc", vec![(field, Value::Int(above))]); + assert_limit(&e, field, &format!("at most {hi}; this is {above}")); + } + // One bound only. + r.create( + "doc", + vec![("floor", Value::Int(-5)), ("ceiling", Value::Int(5))], + ); + r.create( + "doc", + vec![ + ("floor", Value::Int(i64::MAX)), + ("ceiling", Value::Int(i64::MIN)), + ], + ); + assert_limit( + &r.refused("doc", vec![("floor", Value::Int(-6))]), + "floor", + "at least -5; this is -6", + ); + assert_limit( + &r.refused("doc", vec![("ceiling", Value::Int(6))]), + "ceiling", + "at most 5; this is 6", + ); + assert_eq!(r.count("doc"), 6); + }); +} + +#[test] +fn a_decimal_bound_is_exact_to_the_last_digit() { + each(|r| { + for ok in ["0.1000000", "0.1000001", "0.5", "0.9000000"] { + r.create("doc", vec![("rate", t(ok))]); + } + let e = r.refused("doc", vec![("rate", t("0.0999999"))]); + assert_limit(&e, "rate", "at least 0.1000000; this is 0.0999999"); + let e = r.refused("doc", vec![("rate", t("0.9000001"))]); + assert_limit(&e, "rate", "at most 0.9000000; this is 0.9000001"); + // Written another way: the canonical form is compared, not the text. + let e = r.refused("doc", vec![("rate", t("1"))]); + assert_limit(&e, "rate", "at most 0.9000000; this is 1.0000000"); + let e = r.refused("doc", vec![("rate", t("-0.1"))]); + assert_limit(&e, "rate", "at least 0.1000000; this is -0.1000000"); + assert_eq!(r.count("doc"), 4); + }); +} + +#[test] +fn a_refusal_leaves_the_store_alone() { + each(|r| { + let id = r.create("doc", vec![("title", t("first")), ("qty", Value::Int(1))]); + let before = r.read("doc", &id); + // A create with one bad field among good ones writes nothing. + let e = r.refused("doc", vec![("title", t("fine")), ("qty", Value::Int(1000))]); + assert_limit(&e, "qty", "at most 100; this is 1000"); + assert_eq!(r.count("doc"), 1); + // An update with one bad field among good ones changes nothing. + let Err(CallError::Handler(e)) = r.update( + &id, + vec![("title", t("changed")), ("qty", Value::Int(1000))], + ) else { + panic!("the update should have been refused") + }; + assert_limit(&e, "qty", "at most 100; this is 1000"); + assert_eq!(r.read("doc", &id), before); + }); +} + +#[test] +fn update_holds_a_changed_value_to_the_limit() { + each(|r| { + let id = r.create("doc", vec![("title", t("first"))]); + let Err(CallError::Handler(e)) = r.update(&id, vec![("title", t("abcdefghijk"))]) else { + panic!("refused expected") + }; + assert_limit(&e, "title", "at most 10 characters; this has 11"); + let Err(CallError::Handler(e)) = r.update(&id, vec![("title", t("x"))]) else { + panic!("refused expected") + }; + assert_limit(&e, "title", "at least 2 characters; this has 1"); + r.update(&id, vec![("title", t("second"))]).unwrap(); + assert_eq!(text(get(&r.read("doc", &id), "title")), "second"); + }); +} + +#[test] +fn a_record_stored_above_a_lowered_limit_stays_editable_in_its_other_fields() { + each(|r| { + let id = r.create("doc", vec![("title", t("first")), ("qty", Value::Int(5))]); + // Before the limits existed these were fine. + r.corrupt( + "doc", + &id, + "title", + serde_json::json!("a title far longer than ten"), + ); + r.corrupt("doc", &id, "qty", serde_json::json!(5000)); + r.corrupt( + "doc", + &id, + "tiny", + serde_json::json!("much longer than five"), + ); + + // Another field can be changed ... + r.update(&id, vec![("plain", t("edited"))]).unwrap(); + // ... also by a form that sends the whole record back, unchanged fields included. + r.update( + &id, + vec![ + ("title", t("a title far longer than ten")), + ("qty", Value::Int(5000)), + ("tiny", t("much longer than five")), + ("plain", t("edited again")), + ], + ) + .unwrap(); + let row = r.read("doc", &id); + assert_eq!(text(get(&row, "plain")), "edited again"); + assert_eq!(text(get(&row, "title")), "a title far longer than ten"); + + // A changed value is held to the limit, even when it is still above it. + let Err(CallError::Handler(e)) = + r.update(&id, vec![("title", t("a title far longer than eleven"))]) + else { + panic!("refused expected") + }; + assert_limit(&e, "title", "at most 10 characters; this has 30"); + let Err(CallError::Handler(e)) = r.update(&id, vec![("qty", Value::Int(5001))]) else { + panic!("refused expected") + }; + assert_limit(&e, "qty", "at most 100; this is 5001"); + // Going back inside the limit is always possible. + r.update(&id, vec![("title", t("short")), ("qty", Value::Int(7))]) + .unwrap(); + // The other checks still run on an unchanged value: a stored kind error is still one. + r.corrupt("doc", &id, "qty", serde_json::json!("not a number")); + assert!(r.update(&id, vec![("qty", t("not a number"))]).is_err()); + }); +} + +#[test] +fn bulk_create_names_the_row_and_the_limit_without_the_error_code() { + each(|r| { + let rows = Value::List(vec![ + m(vec![("title", t("fine"))]), + m(vec![("title", t("abcdefghijk"))]), + m(vec![("title", t("fine too")), ("qty", Value::Int(101))]), + m(vec![("rate", t("0.0999999"))]), + ]); + let msg = r.err("doc", "bulk_create", m(vec![("rows", rows.clone())])); + assert!( + msg.contains("3 of 4 row(s) are invalid") && msg.contains("nothing was created"), + "{msg}" + ); + assert!( + msg.contains("row 2") + && msg.contains(".title must be at most 10 characters; this has 11") + && msg.contains("row 3") + && msg.contains(".qty must be at most 100; this is 101") + && msg.contains("row 4") + && msg.contains(".rate must be at least 0.1000000; this is 0.0999999"), + "{msg}" + ); + assert!( + !msg.contains("limit_violated"), + "a row error lists the sentence, not the code: {msg}" + ); + assert_eq!(r.count("doc"), 0); + + // Partial mode keeps the row that is fine and reports the others by number. + let report = r.ok( + "doc", + "bulk_create", + m(vec![("rows", rows.clone()), ("mode", t("partial"))]), + ); + assert_eq!(r.count("doc"), 1); + let errs: Vec<(Value, String)> = list(get(&report, "errors")) + .iter() + .map(|e| (get(e, "row").clone(), text(get(e, "error")))) + .collect(); + assert_eq!( + errs.iter().map(|e| e.0.clone()).collect::>(), + [Value::Int(2), Value::Int(3), Value::Int(4)] + ); + assert!( + errs[0] + .1 + .ends_with("must be at most 10 characters; this has 11"), + "{errs:?}" + ); + assert!( + errs.iter().all(|e| !e.1.contains("limit_violated")), + "{errs:?}" + ); + + // A dry run reports and writes nothing. + let report = r.ok( + "doc", + "bulk_create", + m(vec![ + ("rows", rows), + ("dry_run", Value::Bool(true)), + ("mode", t("partial")), + ]), + ); + assert_eq!(get(&report, "failed"), &Value::Int(3)); + assert_eq!(r.count("doc"), 1); + }); +} + +#[test] +fn csv_rows_go_through_the_same_check() { + each(|r| { + let csv = "title,qty\nfine,5\nabcdefghijk,5\nfine,101\n"; + let msg = r.err("doc", "bulk_create", m(vec![("csv", t(csv))])); + assert!( + msg.contains("row 2") && msg.contains("this has 11"), + "{msg}" + ); + assert!( + msg.contains("row 3") && msg.contains("this is 101"), + "{msg}" + ); + assert_eq!(r.count("doc"), 0); + }); +} + +#[test] +fn what_an_action_writes_is_held_to_the_limits_of_the_target() { + each(|r| { + // `finish` copies `plain` into `log.short`, which holds three characters. + let fits = r.create("doc", vec![("title", t("ok")), ("plain", t("abc"))]); + r.ok("doc", "finish", m(vec![("id", t(&fits))])); + assert_eq!(r.count("log"), 1); + + let id = r.create("doc", vec![("title", t("long")), ("plain", t("abcd"))]); + let e = r.err("doc", "finish", m(vec![("id", t(&id))])); + // What an action writes is the action's, not the caller's: the refusal says what the + // field takes and not how long the value was (D90 Decision 5). + assert!(e.contains(".short must be at most 3 characters"), "{e}"); + assert!(!e.contains("this has"), "{e}"); + // The action did not run at all: no log, and the state did not move. + assert_eq!(r.count("log"), 1); + assert_eq!(text(get(&r.read("doc", &id), "state")), "new"); + }); +} + +#[test] +fn an_adjustment_cannot_push_a_number_past_its_bound() { + each(|r| { + let doc = r.create("doc", vec![("title", t("stock")), ("qty", Value::Int(90))]); + let c1 = r.create("counter", vec![("doc_id", t(&doc)), ("by", Value::Int(10))]); + r.ok("counter", "apply", m(vec![("id", t(&c1))])); + assert_eq!(get(&r.read("doc", &doc), "qty"), &Value::Int(100)); + + let c2 = r.create("counter", vec![("doc_id", t(&doc)), ("by", Value::Int(1))]); + let e = r.err("counter", "apply", m(vec![("id", t(&c2))])); + assert!(e.contains(".qty must be at most 100"), "{e}"); + assert!(!e.contains("this is"), "{e}"); + // Nothing moved: the quantity and the state are as before. + assert_eq!(get(&r.read("doc", &doc), "qty"), &Value::Int(100)); + assert_eq!(text(get(&r.read("counter", &c2), "state")), "open"); + }); +} + +#[test] +fn the_refusal_is_a_400_with_its_own_code_and_a_hint() { + each(|r| { + let msg = r.refused("doc", vec![("title", t("abcdefghijk"))]); + let c = errors::classify(&CallError::Handler(msg.clone())); + assert_eq!((c.status, c.code), (400, "limit_violated")); + // The body carries the sentence without the code, the code and the hint. + assert_eq!( + c.body["error"], + msg.strip_prefix("limit_violated: ").unwrap() + ); + assert_eq!(c.body["code"], "limit_violated"); + assert!( + c.body["hint"].as_str().unwrap().contains("max_length"), + "{}", + c.body + ); + // Every other kind of error still reads as before: no code. + let c = errors::classify(&CallError::Handler("lim_doc.qty must be an integer".into())); + assert_eq!((c.status, c.code), (400, "bad_request")); + assert!(c.body.get("code").is_none(), "{}", c.body); + }); +} + +#[test] +fn the_plain_text_of_a_row_error_drops_only_a_code_of_the_table() { + assert_eq!( + errors::plain("limit_violated: a.b must be at most 1 character; this has 2"), + "a.b must be at most 1 character; this has 2" + ); + assert_eq!(errors::plain("a.b is required"), "a.b is required"); + // A colon in an ordinary message is not a code. + assert_eq!( + errors::plain("note: a.b is required"), + "note: a.b is required" + ); +} + +// ---- the review of PR 33 ------------------------------------------------------------------- + +/// The answer of an action whose effect met a limit: `limit_violated` first and once, the place +/// it happened after it, and on every surface the status, code and hint of the table (F0, F8). +fn assert_effect_limit(e: &str, context: &str) { + assert!(e.starts_with("limit_violated: "), "code not first: {e}"); + assert_eq!(e.matches("limit_violated").count(), 1, "code twice: {e}"); + assert!(e.contains(context), "wanted `{context}` in: {e}"); + let c = errors::classify(&CallError::Handler(e.to_string())); + assert_eq!((c.status, c.code), (400, "limit_violated"), "{e}"); + assert_eq!(c.body["code"], "limit_violated"); + let hint = c.body["hint"].as_str().expect("a hint"); + assert!(hint.contains("max_length"), "{hint}"); + // The sentence of the body has no code in it, and the JSON-RPC error of /mcp carries the hint. + let said = c.body["error"].as_str().unwrap(); + assert!(!said.contains("limit_violated"), "{said}"); + assert!(said.contains(context), "{said}"); + assert_eq!(c.hint, Some(hint)); + assert_eq!(c.rpc_data()["hint"], hint); + assert_eq!(c.exit, 1); +} + +#[test] +fn an_effect_that_meets_a_limit_keeps_the_code_and_the_hint() { + each(|r| { + // 1. A create effect: `finish` copies four characters into a field that holds three. + let id = r.create("doc", vec![("title", t("long")), ("plain", t("abcd"))]); + let e = r.err("doc", "finish", m(vec![("id", t(&id))])); + assert_effect_limit( + &e, + &format!( + "{}: effect 1 (create) failed, nothing was changed: {}_log.short must be at most 3 characters", + r.cmd("doc", "finish"), + r.p + ), + ); + + // 2. An adjust effect: `apply` pushes the quantity past its bound. + let doc = r.create("doc", vec![("title", t("stock")), ("qty", Value::Int(100))]); + let c = r.create("counter", vec![("doc_id", t(&doc)), ("by", Value::Int(1))]); + let e = r.err("counter", "apply", m(vec![("id", t(&c))])); + assert_effect_limit( + &e, + &format!( + "{}: effect 1 (adjust) failed, nothing was changed: {}_doc.qty must be at most 100", + r.cmd("counter", "apply"), + r.p + ), + ); + + // 3. A nested `set_state`: `batch.run` starts `counter.apply`, whose adjust meets the bound. + let b = r.create("batch", vec![("counter_id", t(&c))]); + let e = r.err("batch", "run", m(vec![("id", t(&b))])); + assert_effect_limit( + &e, + &format!( + "{}: effect 1 (set_state) failed, nothing was changed: {}_counter.apply on the related record: {}: effect 1 (adjust) failed", + r.cmd("batch", "run"), + r.p, + r.cmd("counter", "apply"), + ), + ); + // None of the three changed anything. + assert_eq!(r.count("log"), 0); + assert_eq!(get(&r.read("doc", &doc), "qty"), &Value::Int(100)); + assert_eq!(text(get(&r.read("counter", &c), "state")), "open"); + assert_eq!(text(get(&r.read("batch", &b), "state")), "open"); + }); +} + +#[test] +fn an_error_of_an_effect_that_is_not_a_limit_is_wrapped_as_before() { + // Only `limit_violated` moves to the front: the wrapper of every other refusal is unchanged. + let outer = "x.y: effect 1 (create) failed, nothing was changed"; + assert_eq!( + errors::wrap(outer, "no lead nope"), + format!("{outer}: no lead nope") + ); + assert_eq!( + errors::wrap(outer, "assertion_failed: stays where it was"), + format!("{outer}: assertion_failed: stays where it was") + ); + assert_eq!( + errors::wrap(outer, "limit_violated: a.b must be at most 1 character"), + "limit_violated: x.y: effect 1 (create) failed, nothing was changed: a.b must be at most 1 character" + ); +} + +/// A name of 40 characters (an agent never reads it, only its token). +const LONG_NAME: &str = "Aleksandra Katarzyna Kowalska-Nowakowska"; + +#[test] +fn an_agent_is_not_told_how_long_a_value_it_only_holds_as_a_token_is() { + assert_eq!(LONG_NAME.chars().count(), 40); + each(|r| { + let doc = r.create("doc", vec![("title", t("ok")), ("person", t(LONG_NAME))]); + let bot = r.bot(); + let mut vault = Vault::new(); + // The agent reads the record and holds the name as a token. + let row = r + .call_as( + &bot, + Surface::Mcp, + &mut vault, + "doc", + "get", + m(vec![("id", t(&doc))]), + ) + .unwrap() + .value; + let token = text(get(&row, "person")); + assert_ne!(token, LONG_NAME, "the agent must hold a token"); + + // It writes the token where three characters fit. The handler sees the real name, 40 + // characters; the refusal must not say so. + let e = r.err_as( + &bot, + Surface::Mcp, + &mut vault, + "log", + "create", + m(vec![("short", t(&token))]), + ); + assert_eq!( + e, + format!( + "limit_violated: {}_log.short must be at most 3 characters", + r.p + ) + ); + // The same through update, bulk_create and a number the agent wrote itself. + let e = r.err_as( + &bot, + Surface::Mcp, + &mut vault, + "doc", + "update", + m(vec![("id", t(&doc)), ("title", t(&token))]), + ); + assert_eq!( + e, + format!( + "limit_violated: {}_doc.title must be at most 10 characters", + r.p + ) + ); + let e = r.err_as( + &bot, + Surface::Mcp, + &mut vault, + "doc", + "create", + m(vec![("title", t("ok")), ("qty", Value::Int(1000))]), + ); + assert_eq!( + e, + format!("limit_violated: {}_doc.qty must be at most 100", r.p) + ); + let e = r.err_as( + &bot, + Surface::Mcp, + &mut vault, + "doc", + "bulk_create", + m(vec![( + "rows", + Value::List(vec![m(vec![("title", t(&token))])]), + )]), + ); + assert!(e.contains("must be at most 10 characters"), "{e}"); + assert!(!e.contains("this has") && !e.contains("40"), "{e}"); + assert_eq!(r.count("log"), 0); + + // A person who typed the value, on the other hand, is told how long it was. + let e = r.refused("log", vec![("short", t(LONG_NAME))]); + assert_limit(&e, "short", "at most 3 characters; this has 40"); + }); +} + +#[test] +fn an_action_never_states_the_value_it_copied_from_a_field_the_caller_cannot_read() { + each(|r| { + // The salary is in the HR group; `pay` copies it into `log.cap`, which holds at most 100. + let hr = r.hr(); + let id = text(get( + &r.call_as( + &hr, + Surface::Rest, + &mut Vault::new(), + "payroll", + "create", + m(vec![("salary", Value::Int(73_250))]), + ) + .unwrap() + .value, + "id", + )); + let want = format!( + "limit_violated: {}: effect 1 (create) failed, nothing was changed: {}_log.cap must be at most 100", + r.cmd("payroll", "pay"), + r.p + ); + let people = [ + ("an ordinary user", r.who.clone(), Surface::Rest), + ("an agent", r.bot(), Surface::Mcp), + // Even the person who can read the salary: what the action wrote is not theirs. + ("the HR person", hr.clone(), Surface::Rest), + ]; + for (who, p, surface) in people { + let e = r.err_as( + &p, + surface, + &mut Vault::new(), + "payroll", + "pay", + m(vec![("id", t(&id))]), + ); + assert_eq!(e, want, "{who}"); + } + // Nothing was written, and the state did not move. + assert_eq!(r.count("log"), 0); + let row = r + .call_as( + &hr, + Surface::Rest, + &mut Vault::new(), + "payroll", + "get", + m(vec![("id", t(&id))]), + ) + .unwrap() + .value; + assert_eq!(text(get(&row, "state")), "new"); + }); +} + +#[test] +fn a_decimal_breach_states_no_value_to_an_agent_or_from_an_effect() { + each(|r| { + // A person who typed the decimal is told what it was. + let e = r.refused("log", vec![("ratio", t("1.00"))]); + assert_limit(&e, "ratio", "at most 0.90; this is 1.00"); + // An agent is not, even for what it typed itself. + let e = r.err_as( + &r.bot(), + Surface::Mcp, + &mut Vault::new(), + "log", + "create", + m(vec![("ratio", t("1.00"))]), + ); + assert_eq!( + e, + format!("limit_violated: {}_log.ratio must be at most 0.90", r.p) + ); + // An action that copies a decimal the caller may not read into the bounded field never + // states it, whoever runs it. + let hr = r.hr(); + let id = text(get( + &r.call_as( + &hr, + Surface::Rest, + &mut Vault::new(), + "payroll", + "create", + m(vec![("bonus", t("12.50"))]), + ) + .unwrap() + .value, + "id", + )); + let want = format!( + "limit_violated: {}: effect 1 (create) failed, nothing was changed: {}_log.ratio must be at most 0.90", + r.cmd("payroll", "grant"), + r.p + ); + for (who, p, surface) in [ + ("an ordinary user", r.who.clone(), Surface::Rest), + ("an agent", r.bot(), Surface::Mcp), + ("the HR person", hr.clone(), Surface::Rest), + ] { + let e = r.err_as( + &p, + surface, + &mut Vault::new(), + "payroll", + "grant", + m(vec![("id", t(&id))]), + ); + assert_eq!(e, want, "{who}"); + } + assert_eq!(r.count("log"), 0); + }); +} + +#[test] +fn a_state_move_with_an_over_limit_field_is_refused_before_the_action_runs() { + each(|r| { + // `state` is governed: `update` moves it only as `finish`, which writes a log record. + let id = r.create("doc", vec![("title", t("ok")), ("plain", t("ab"))]); + let Err(CallError::Handler(e)) = + r.update(&id, vec![("state", t("done")), ("title", t("abcdefghijk"))]) + else { + panic!("the update should have been refused") + }; + assert_limit(&e, "title", "at most 10 characters; this has 11"); + // The action did not run: no log record, the state did not move, the title is as it was. + assert_eq!( + r.count("log"), + 0, + "the action ran before the update was refused" + ); + let row = r.read("doc", &id); + assert_eq!(text(get(&row, "state")), "new"); + assert_eq!(text(get(&row, "title")), "ok"); + // An agent that holds a name as a token and sends it as the title along with the move + // is refused the same way and is not told how long the name behind the token is. + let named = r.create("doc", vec![("title", t("ok")), ("person", t(LONG_NAME))]); + let (bot, mut vault) = (r.bot(), Vault::new()); + let seen = r + .call_as( + &bot, + Surface::Mcp, + &mut vault, + "doc", + "get", + m(vec![("id", t(&named))]), + ) + .unwrap() + .value; + let token = text(get(&seen, "person")); + assert_ne!(token, LONG_NAME, "the agent must hold a token"); + let e = r.err_as( + &bot, + Surface::Mcp, + &mut vault, + "doc", + "update", + m(vec![ + ("id", t(&id)), + ("state", t("done")), + ("title", t(&token)), + ]), + ); + assert_eq!( + e, + format!( + "limit_violated: {}_doc.title must be at most 10 characters", + r.p + ) + ); + assert_eq!(r.count("log"), 0, "the action ran for the agent"); + assert_eq!(text(get(&r.read("doc", &id), "state")), "new"); + // Nothing wrong with the same move once the title fits. + r.update(&id, vec![("state", t("done")), ("title", t("fits"))]) + .unwrap(); + assert_eq!(r.count("log"), 1); + assert_eq!(text(get(&r.read("doc", &id), "state")), "done"); + }); +} + +#[test] +fn a_state_move_that_sends_back_an_over_limit_stored_value_is_not_refused_for_it() { + each(|r| { + let id = r.create("doc", vec![("title", t("ok")), ("plain", t("ab"))]); + // Stored before a limit was lowered, and sent back by a form with the state change. + r.corrupt("doc", &id, "title", serde_json::json!("longer than ten")); + r.update( + &id, + vec![("state", t("done")), ("title", t("longer than ten"))], + ) + .unwrap(); + assert_eq!(r.count("log"), 1); + let row = r.read("doc", &id); + assert_eq!(text(get(&row, "state")), "done"); + assert_eq!(text(get(&row, "title")), "longer than ten"); + }); +} + +#[test] +fn a_decimal_stored_above_a_lowered_bound_stays_editable_whatever_its_spelling() { + each(|r| { + // `rate` holds 0.1 to 0.9 now; 5.0 was fine before the bound was set. + let id = r.create("doc", vec![("title", t("first")), ("rate", t("0.5"))]); + r.corrupt("doc", &id, "rate", serde_json::json!("5.0000000")); + r.update(&id, vec![("plain", t("edited"))]).unwrap(); + // The same number written as 5, 5.0 or in full is the stored value: not refused. + for (i, spelling) in ["5", "5.0", "5.0000000"].into_iter().enumerate() { + r.update( + &id, + vec![("rate", t(spelling)), ("plain", t(&format!("e{i}")))], + ) + .unwrap_or_else(|e| panic!("{spelling}: {e:?}")); + } + assert_eq!(text(get(&r.read("doc", &id), "rate")), "5.0000000"); + // A different value is held to the bound, however close to the stored one. + for other in ["6", "5.0000001", "0.9000001"] { + let Err(CallError::Handler(e)) = r.update(&id, vec![("rate", t(other))]) else { + panic!("{other} should have been refused") + }; + assert_limit(&e, "rate", "at most 0.9000000; this is "); + } + // Going back inside is always possible. + r.update(&id, vec![("rate", t("0.3"))]).unwrap(); + assert_eq!(text(get(&r.read("doc", &id), "rate")), "0.3000000"); + }); +} diff --git a/crates/kernel/obop-commands/src/public.rs b/crates/kernel/obop-commands/src/public.rs index 5165c467..23e57d44 100644 --- a/crates/kernel/obop-commands/src/public.rs +++ b/crates/kernel/obop-commands/src/public.rs @@ -948,7 +948,7 @@ impl PublicApi { crate::kinds::canonicalise_values(&def, &mut fields, crate::kinds::Path::Write) .map_err(SubmitError::Rejected)?; validate_with(&def, &self.store, &fields, true, &pending) - .map_err(SubmitError::Rejected)?; + .map_err(|e| SubmitError::Rejected(crate::errors::plain(&e).to_string()))?; let id = uuid::Uuid::now_v7().to_string(); pending.push((s.model.clone(), id.clone())); rows.push(( diff --git a/crates/kernel/obop-commands/src/public/tests.rs b/crates/kernel/obop-commands/src/public/tests.rs index cb856541..4cd69fd7 100644 --- a/crates/kernel/obop-commands/src/public/tests.rs +++ b/crates/kernel/obop-commands/src/public/tests.rs @@ -1,5 +1,5 @@ use super::*; -use obop_registry::{CommandDef, FieldDef, Module}; +use obop_registry::{Bound, CommandDef, FieldDef, Limits, Module}; fn f(name: &'static str) -> FieldDef { FieldDef::new(name, "d", DataClass::None, false, "t.user") @@ -48,6 +48,16 @@ fn module() -> Module { FieldDef::new("email", "d", DataClass::Internal, true, "t.user").required(), f("body"), f("flag"), + f("qty").kind(FieldKind::Number).with_limits(Limits { + max: Some(Bound::Int(10)), + ..Default::default() + }), + f("rate").decimal(8, 2).with_limits(Limits { + max: Some(Bound::Dec( + obop_values::Decimal::parse_for("5.00", 8, 2, None).unwrap(), + )), + ..Default::default() + }), f("page_id").reference("page"), f("state").select(&["new", "done"]), ], @@ -107,7 +117,13 @@ fn specs() -> Vec { model: "msg".into(), read: None, submit: Some(PublicSubmit { - fields: vec!["email".into(), "body".into(), "page_id".into()], + fields: vec![ + "email".into(), + "body".into(), + "page_id".into(), + "qty".into(), + "rate".into(), + ], rate_limit_per_hour: 3, }), receipt: None, @@ -465,3 +481,67 @@ fn site_settings_reads_only_the_site_object() { assert_eq!(site["title"], "Shop"); assert!(!site.contains_key("theme")); } + +#[test] +fn a_visitor_cannot_send_a_text_longer_than_the_field_allows() { + // D90: the anonymous submit path checks what the form of a visitor sends like any other write. + let (api, s) = api(); + let default = obop_registry::DEFAULT_TEXT_MAX as usize; + let ok = msg(&[("email", t("a@b.test")), ("body", t(&"ä".repeat(default)))]); + api.submit_batch("lim1", vec![ok]).unwrap(); + assert_eq!(s.count("msg"), 1); + + let long = msg(&[ + ("email", t("a@b.test")), + ("body", t(&"ä".repeat(default + 1))), + ]); + let e = api.submit_batch("lim2", vec![long]); + let want = format!( + "msg.body must be at most {default} characters; this has {}", + default + 1 + ); + assert!( + matches!(&e, Err(SubmitError::Rejected(m)) if *m == want), + "{e:?}" + ); + // The batch is all or nothing: a good submission next to the long one is not kept either. + let good = msg(&[("email", t("b@b.test"))]); + let long = msg(&[ + ("email", t("a@b.test")), + ("body", t(&"x".repeat(default + 1))), + ]); + assert!(api.submit_batch("lim3", vec![good, long]).is_err()); + assert_eq!(s.count("msg"), 1); +} + +#[test] +fn a_visitor_cannot_send_a_number_or_a_decimal_past_its_bound() { + // D90 (review of PR 33, F10): the anonymous path holds `number` and `decimal` to `max` as it + // holds a text to its length, and states what the visitor sent (the visitor typed it). + let (api, s) = api(); + let rejected = |label: &str, kv: &[(&str, Value)]| { + let mut all = vec![("email", t("a@b.test"))]; + all.extend_from_slice(kv); + match api.submit_batch(label, vec![msg(&all)]) { + Err(SubmitError::Rejected(m)) => m, + other => panic!("{label}: wanted a rejection, got {other:?}"), + } + }; + assert_eq!( + rejected("n1", &[("qty", Value::Int(11))]), + "msg.qty must be at most 10; this is 11" + ); + assert_eq!( + rejected("d1", &[("rate", t("5.01"))]), + "msg.rate must be at most 5.00; this is 5.01" + ); + assert_eq!(s.count("msg"), 0); + // The bound itself is inside it. + let ok = msg(&[ + ("email", t("a@b.test")), + ("qty", Value::Int(10)), + ("rate", t("5")), + ]); + api.submit_batch("n2", vec![ok]).unwrap(); + assert_eq!(s.count("msg"), 1); +} diff --git a/crates/kernel/obop-commands/src/store.rs b/crates/kernel/obop-commands/src/store.rs index b8273d64..49cdd213 100644 --- a/crates/kernel/obop-commands/src/store.rs +++ b/crates/kernel/obop-commands/src/store.rs @@ -4,7 +4,7 @@ use crate::engine::{Engine, crud, seq}; use crate::{Dispatcher, Value, guards}; -use obop_registry::{FieldKind, ModelDef, Module, is_valid_date}; +use obop_registry::{FieldDef, FieldKind, ModelDef, Module, is_valid_date}; use obop_storage::blob::{Blobs, Catalog}; use obop_storage::{Backend, Fields}; use std::collections::BTreeMap; @@ -144,24 +144,34 @@ pub(crate) fn declared_fields( /// Check one non-null value against its field kind (D47). Tokens are already restored by the /// dispatcher, so this always sees real values. +/// The only caller is the public site's submission (D90): the visitor typed every value, so a +/// limit refusal may state its length or number (`true`); nothing here is read from a record. fn check_value( store: &Store, pending: &[(String, String)], model: &str, - name: &str, - kind: &FieldKind, + f: &FieldDef, v: &Value, ) -> Result<(), String> { + let (name, kind) = (f.name, &f.kind); match (kind, v) { - (FieldKind::Text | FieldKind::LongText, Value::Text(_)) => Ok(()), - (FieldKind::Number | FieldKind::Money, Value::Int(_)) => Ok(()), + (FieldKind::Text | FieldKind::LongText, Value::Text(s)) => { + crate::kinds::check_text_limits(model, f, s, true) + } + (FieldKind::Number | FieldKind::Money, Value::Int(n)) => { + crate::kinds::check_int_limits(model, f, *n, true) + } (FieldKind::Bool, Value::Bool(_)) => Ok(()), (FieldKind::Image(_) | FieldKind::File(_), Value::Text(id)) => { crate::blobs::check_field_value(store, model, name, kind, id) } (FieldKind::Date, Value::Text(t)) if is_valid_date(t) => Ok(()), (FieldKind::Decimal { .. } | FieldKind::Datetime, Value::Text(t)) => { - crate::kinds::check_canonical(model, name, kind, t) + crate::kinds::check_canonical(model, name, kind, t)?; + if matches!(kind, FieldKind::Decimal { .. }) { + crate::kinds::check_decimal_limits(model, f, t, true)?; + } + Ok(()) } (FieldKind::Select(o), Value::Text(t)) if o.contains(&t.as_str()) => Ok(()), (FieldKind::Ref(to), Value::Text(id)) @@ -213,7 +223,7 @@ pub fn validate_with( )); } Some(Value::Null) => {} - Some(v) => check_value(store, pending, def.name, f.name, &f.kind, v)?, + Some(v) => check_value(store, pending, def.name, f, v)?, } } Ok(()) diff --git a/crates/kernel/obop-http/src/openapi.rs b/crates/kernel/obop-http/src/openapi.rs index c59cf069..a5b828e1 100644 --- a/crates/kernel/obop-http/src/openapi.rs +++ b/crates/kernel/obop-http/src/openapi.rs @@ -5,6 +5,32 @@ use obop_commands::ModuleInfo; use serde_json::{Value as J, json}; +/// The length and range limits of a field (D90) as JSON Schema keywords of its input schema: +/// `maxLength` and `minLength`, `minimum` and `maximum`. A decimal is a string, which JSON Schema +/// cannot bound by value, so its bounds are the extensions `x-obop-minimum` and `x-obop-maximum` +/// (the canonical text). The same as the MCP tool schemas. +fn limit_keywords(f: &obop_commands::FieldInfo) -> serde_json::Map { + let mut kw = serde_json::Map::new(); + if let Some(n) = f.max_length { + kw.insert("maxLength".into(), json!(n)); + } + if let Some(n) = f.min_length { + kw.insert("minLength".into(), json!(n)); + } + let (lo, hi) = if f.kind == "decimal" { + ("x-obop-minimum", "x-obop-maximum") + } else { + ("minimum", "maximum") + }; + if let Some(b) = f.min { + kw.insert(lo.into(), obop_mcp::schema::bound_json(b)); + } + if let Some(b) = f.max { + kw.insert(hi.into(), obop_mcp::schema::bound_json(b)); + } + kw +} + fn error_schema() -> J { json!({ "type": "object", @@ -221,6 +247,10 @@ pub fn instance(base: &str, modules: &[ModuleInfo]) -> J { let name = &model.name; let mut props = serde_json::Map::new(); let mut required = Vec::new(); + // The limits of the fields (D90) are keywords of the input schemas only: a record that was + // stored before a limit was lowered still has to validate as a response. + let mut limit_kw: std::collections::HashMap> = + Default::default(); for f in &model.fields { let options: Vec = f.options.iter().map(|o| o.to_string()).collect(); let mut sch = match f.kind { @@ -275,6 +305,8 @@ pub fn instance(base: &str, modules: &[ModuleInfo]) -> J { desc.push_str(" (a number the system assigns; read-only)"); sch["readOnly"] = J::from(true); } + desc.push_str(&f.limits_note); + limit_kw.insert(f.name.to_string(), limit_keywords(f)); if f.unique && !f.sequence { desc.push_str(" (unique)"); } @@ -293,7 +325,13 @@ pub fn instance(base: &str, modules: &[ModuleInfo]) -> J { let input_props: serde_json::Map = props .iter() .filter(|(_, v)| v.get("readOnly") != Some(&J::from(true))) - .map(|(k, v)| (k.clone(), v.clone())) + .map(|(k, v)| { + let mut v = v.clone(); + for (kw, value) in limit_kw.get(k).into_iter().flatten() { + v[kw] = value.clone(); + } + (k.clone(), v) + }) .collect(); schemas.insert(name.clone(), rec.clone()); let op = |id: &str, summary: &str, body: Option, resp: J| { diff --git a/crates/kernel/obop-mcp/Cargo.toml b/crates/kernel/obop-mcp/Cargo.toml index 44014deb..c076f645 100644 --- a/crates/kernel/obop-mcp/Cargo.toml +++ b/crates/kernel/obop-mcp/Cargo.toml @@ -14,6 +14,7 @@ serde_json = "1" [dev-dependencies] obop-registry.workspace = true +obop-values.workspace = true [lints] workspace = true diff --git a/crates/kernel/obop-mcp/src/lib.rs b/crates/kernel/obop-mcp/src/lib.rs index 6872459a..3754cfb8 100644 --- a/crates/kernel/obop-mcp/src/lib.rs +++ b/crates/kernel/obop-mcp/src/lib.rs @@ -406,6 +406,20 @@ pub fn meta_json(d: &Dispatcher, surface: Surface, p: &Principal) -> J { if let Some(n) = f.max_bytes { o["max_bytes"] = json!(n); } + // Length and range limits (D90). `max_length` is always there for + // a text field: the field's own, else the default of the kind. + if let Some(n) = f.max_length { + o["max_length"] = json!(n); + } + if let Some(n) = f.min_length { + o["min_length"] = json!(n); + } + if let Some(b) = f.min { + o["min"] = schema::bound_json(b); + } + if let Some(b) = f.max { + o["max"] = schema::bound_json(b); + } if f.kind == "decimal" { o["precision"] = json!(f.precision); o["scale"] = json!(f.scale); diff --git a/crates/kernel/obop-mcp/src/schema.rs b/crates/kernel/obop-mcp/src/schema.rs index 0060ffdf..c20450f9 100644 --- a/crates/kernel/obop-mcp/src/schema.rs +++ b/crates/kernel/obop-mcp/src/schema.rs @@ -2,7 +2,9 @@ //! command, named `__`, with a JSON Schema input, an output schema and //! annotations. Both transports list tools through this module, so they cannot diverge. -use obop_commands::{CommandInfo, Dispatcher, FieldInfo, ModelInfo, Principal, Surface, names}; +use obop_commands::{ + Bound, CommandInfo, Dispatcher, FieldInfo, ModelInfo, Principal, Surface, names, +}; use serde_json::{Value as J, json}; use std::collections::HashMap; @@ -25,6 +27,7 @@ pub fn field_schema(f: &FieldInfo) -> J { "select" => json!({"type": "string", "enum": f.options}), _ => json!({"type": "string"}), }; + limit_keywords(&mut s, f); let mut desc = f.description.to_string(); match f.kind { "money" => desc.push_str(" (integer, minor currency units such as cents; never a float)"), @@ -47,6 +50,7 @@ pub fn field_schema(f: &FieldInfo) -> J { ), _ => {} } + desc.push_str(&f.limits_note); if let Some(of) = &f.shows_option_of { desc.push_str(&format!( " (may hold an option key of {of}; the back office shows that option's label, the stored text is the key)" @@ -75,6 +79,42 @@ pub fn field_schema(f: &FieldInfo) -> J { s } +/// A bound as JSON: a number for `number` and `money`, the canonical text for `decimal`. +pub fn bound_json(b: Bound) -> J { + match b { + Bound::Int(n) => json!(n), + Bound::Dec(d) => json!(d.to_string()), + } +} + +/// The length and range limits of a field (D90) as JSON Schema keywords: `maxLength` and +/// `minLength` on a string, `minimum` and `maximum` on an integer. A decimal is a string, which +/// JSON Schema cannot bound by value, so its bounds are the extensions `x-obop-minimum` and +/// `x-obop-maximum` (the canonical text). Fields of personal data get none: an agent sees them as +/// mask tokens (`PERSON_1`), which are not the length or the size of the value. +fn limit_keywords(s: &mut J, f: &FieldInfo) { + if f.personal_data { + return; + } + if let Some(n) = f.max_length { + s["maxLength"] = json!(n); + } + if let Some(n) = f.min_length { + s["minLength"] = json!(n); + } + let (lo, hi) = if f.kind == "decimal" { + ("x-obop-minimum", "x-obop-maximum") + } else { + ("minimum", "maximum") + }; + if let Some(b) = f.min { + s[lo] = bound_json(b); + } + if let Some(b) = f.max { + s[hi] = bound_json(b); + } +} + fn id_property(model: &str) -> J { json!({"type": "string", "description": format!("The id of the {model} record.")}) } @@ -378,6 +418,11 @@ mod tests { sequence: false, shows_option_of: None, shows_option_when: None, + max_length: None, + min_length: None, + min: None, + max: None, + limits_note: String::new(), } } @@ -466,6 +511,11 @@ mod tests { sequence: false, shows_option_of: None, shows_option_when: None, + max_length: None, + min_length: None, + min: None, + max: None, + limits_note: String::new(), }; ModelInfo { name: "doc".into(), @@ -550,4 +600,80 @@ mod tests { ); assert!(output_schema("search")["properties"]["value"]["properties"]["total"].is_object()); } + + fn limited(kind: &'static str, edit: impl FnOnce(&mut FieldInfo)) -> J { + let mut f = field(kind); + edit(&mut f); + field_schema(&f) + } + + #[test] + fn a_text_field_says_its_length_limits() { + let s = limited("text", |f| { + f.max_length = Some(2000); + f.min_length = Some(1); + f.limits_note = " (from 1 character to 2000 characters)".into(); + }); + assert_eq!( + (s["maxLength"].clone(), s["minLength"].clone()), + (json!(2000), json!(1)) + ); + assert!( + s["description"] + .as_str() + .unwrap() + .starts_with("a field (from 1 character to 2000 characters)") + ); + // The default is a keyword too (the tool schema is where an agent looks), but it adds + // no words to the description. + let s = limited("longtext", |f| f.max_length = Some(100_000)); + assert_eq!(s["maxLength"], 100_000); + assert!(s.get("minLength").is_none()); + assert_eq!(s["description"], "a field"); + } + + #[test] + fn a_number_says_its_range_and_a_decimal_does_it_as_exact_strings() { + for kind in ["number", "money"] { + let s = limited(kind, |f| { + f.min = Some(Bound::Int(-5)); + f.max = Some(Bound::Int(100)); + }); + assert_eq!( + (s["minimum"].clone(), s["maximum"].clone()), + (json!(-5), json!(100)), + "{kind}" + ); + assert!(s.get("x-obop-minimum").is_none()); + } + let d = |s: &str| Bound::Dec(obop_values::Decimal::parse_for(s, 12, 2, None).unwrap()); + let s = limited("decimal", |f| { + f.precision = Some(12); + f.scale = Some(2); + f.min = Some(d("0.10")); + f.max = Some(d("99.90")); + }); + assert_eq!( + (s["x-obop-minimum"].clone(), s["x-obop-maximum"].clone()), + (json!("0.10"), json!("99.90")) + ); + assert!( + s.get("minimum").is_none() && s.get("maximum").is_none(), + "{s}" + ); + } + + #[test] + fn personal_data_gets_no_keyword_because_an_agent_sees_a_token() { + let s = limited("text", |f| { + f.personal_data = true; + f.class = "internal"; + f.max_length = Some(5); + f.min_length = Some(3); + }); + assert!( + s.get("maxLength").is_none() && s.get("minLength").is_none(), + "{s}" + ); + } } diff --git a/crates/kernel/obop-ops/src/backup.rs b/crates/kernel/obop-ops/src/backup.rs index fe6a7401..38222c17 100644 --- a/crates/kernel/obop-ops/src/backup.rs +++ b/crates/kernel/obop-ops/src/backup.rs @@ -6,7 +6,7 @@ //! at a time, so a large database never sits in memory and a running server is not stopped: //! //! ```text -//! {"type":"header","format":1,"core_version":"0.2.0","schema_version":2,"created_at":"...","source":"postgres"} +//! {"type":"header","format":1,"core_version":"0.2.1","schema_version":2,"created_at":"...","source":"postgres"} //! {"m":"","i":"","d":{...fields...}} one line per record, in insertion order //! {"blob":"","ct":"image/png","w":640,"h":480,"created":1,"x":""} //! {"type":"end","records":N,"blobs":M,"content_sha256":"..."} diff --git a/crates/kernel/obop-pkg/src/dependency_tests.rs b/crates/kernel/obop-pkg/src/dependency_tests.rs index e776894d..3310bdb0 100644 --- a/crates/kernel/obop-pkg/src/dependency_tests.rs +++ b/crates/kernel/obop-pkg/src/dependency_tests.rs @@ -8,6 +8,7 @@ use crate::marketplace::{Listing, Marketplace}; use obop_commands::{ ADMIN_GROUP, CallError, Dispatcher, Principal, Response, Store, Surface, Value, Vault, }; +use std::collections::BTreeMap; use std::sync::Arc; /// A marketplace backed by a list of manifest texts. @@ -644,3 +645,62 @@ fn a_remote_index_serves_listings_and_leaves_out_malformed_ones() { ); assert_eq!(l[0].license, "MIT"); } + +#[test] +fn a_float_set_default_is_refused_on_install_and_upgrade_but_not_in_an_installed_package() { + // Closure check of PR 33 (D90, F19): the previous core let a float through `set_default` for a + // number or money field, so a package that is installed already may carry one, and it has to + // keep loading, upgrading and rolling back. Only what comes in now is refused. + let with_step = |version: &str, value: &str| { + format!( + "{}[[model.field]]\nname = \"qty\"\ndescription = \"Qty\"\nclass = \"none\"\nkind = \"number\"\n[[migration]]\nfrom = \"<0.2.0\"\nop = \"set_default\"\nmodel = \"fl_item\"\nfield = \"qty\"\nvalue = {value}\n", + pkg("fl", version, "^0.2", &[]) + ) + }; + let store = Store::new(); + let (d, inst) = setup(vec![], &store); + // A fresh install with a float is refused, and so is the same through the admin command. + let e = inst + .install(&with_step("0.2.0", "0.0"), "upload", "root") + .unwrap_err(); + assert!(e.contains("does not fit the number field qty"), "{e}"); + assert!(!d.has_model("fl_item")); + inst.install(&with_step("0.2.0", "0"), "upload", "root") + .unwrap(); + // The stored copy as the previous core wrote it: with a float. + store + .update( + "obop_package", + "fl", + BTreeMap::from([( + "manifest".to_string(), + Value::text(&with_step("0.2.0", "0.0")), + )]), + ) + .unwrap(); + // A restart still loads it. + let (d2, inst2) = setup(vec![], &store); + assert!(inst2.load_installed().is_empty(), "the module must load"); + assert!(d2.has_model("fl_item")); + // An upgrade that brings a float is refused, nothing changes ... + let e = handler_error(call( + &d2, + &admin(), + "module.install", + Value::map([("manifest", Value::text(&with_step("0.3.0", "2.5")))]), + )); + assert!(e.contains("does not fit the number field qty"), "{e}"); + assert_eq!( + text(get(row(&inst2.module_rows(), "fl"), "version")), + "0.2.0" + ); + // ... and one that does not goes through, although the installed copy carries a float. + let r = call( + &d2, + &admin(), + "module.install", + Value::map([("manifest", Value::text(&with_step("0.3.0", "1")))]), + ) + .unwrap(); + assert_eq!(step_ids(&r.value), ["fl:upgrade"]); +} diff --git a/crates/kernel/obop-pkg/src/lib.rs b/crates/kernel/obop-pkg/src/lib.rs index 3825e277..d0b48dd7 100644 --- a/crates/kernel/obop-pkg/src/lib.rs +++ b/crates/kernel/obop-pkg/src/lib.rs @@ -29,6 +29,8 @@ mod dependency_tests; #[cfg(test)] mod ledger_tests; #[cfg(test)] +mod limits_tests; +#[cfg(test)] mod public_tests; #[cfg(test)] mod update_tests; diff --git a/crates/kernel/obop-pkg/src/limits_tests.rs b/crates/kernel/obop-pkg/src/limits_tests.rs new file mode 100644 index 00000000..60d46aff --- /dev/null +++ b/crates/kernel/obop-pkg/src/limits_tests.rs @@ -0,0 +1,527 @@ +//! Field limits (D90) in packages: the keys `max_length`, `min_length`, `min` and `max` of +//! `[[model.field]]`, what the manifest parser and the install gate refuse (naming the field and +//! the key), the version a package that uses them declares, and the upgrade diff. + +use crate::manifest::Manifest; +use crate::migrate::diff; +use crate::resolve::{World, plan_install}; +use crate::semver::{Req, Version}; +use crate::{Bundled, Marketplace}; +use obop_registry::{Bound, DEFAULT_TEXT_MAX, check_module}; +use std::collections::BTreeMap; + +/// A package with one model and the given fields; `core` is the `requires_core` it declares. +fn pkg_with_core(core: &str, version: &str, fields: &str) -> String { + format!( + "[module]\nid = \"lim\"\nversion = \"{version}\"\nname = \"Lim\"\ndescription = \"d\"\nlicense = \"MIT\"\nrequires_core = \"{core}\"\n\n[[model]]\nname = \"lim_doc\"\ndescription = \"d\"\nread_group = \"lim.user\"\nwrite_group = \"lim.user\"\n{fields}" + ) +} + +fn pkg(fields: &str) -> String { + pkg_with_core("^0.2.1", "0.1.0", fields) +} + +fn field(name: &str, extra: &str) -> String { + format!( + "\n[[model.field]]\nname = \"{name}\"\ndescription = \"d\"\nclass = \"none\"\n{extra}\n" + ) +} + +/// The error of parsing, building and gating a package, or `None` when it is fine. +fn problem(text: &str) -> Option { + let m = match Manifest::parse(text) { + Ok(m) => m, + Err(e) => return Some(e), + }; + match m.to_module() { + Ok(module) => { + let v = check_module(&module); + (!v.is_empty()).then(|| format!("{v:?}")) + } + Err(e) => Some(e), + } +} + +#[test] +fn every_limit_key_is_read_into_the_field() { + let text = pkg(&[ + field("title", "max_length = 100\nmin_length = 2"), + field("notes", "kind = \"longtext\"\nmax_length = 50000"), + field("qty", "kind = \"number\"\nmin = 0\nmax = 100"), + field("price", "kind = \"money\"\nmin = -500\nmax = 100000"), + field( + "rate", + "kind = \"decimal\"\nprecision = 12\nscale = 7\nmin = \"0.1000000\"\nmax = \"0.9000000\"", + ), + // A whole number is a fine way to write a decimal bound; it takes the scale of the field. + field( + "share", + "kind = \"decimal\"\nprecision = 5\nscale = 2\nmin = 0\nmax = 1", + ), + field("plain", ""), + ] + .concat()); + assert_eq!(problem(&text), None); + let module = Manifest::parse(&text).unwrap().to_module().unwrap(); + let by = |n: &str| { + module.models[0] + .fields + .iter() + .find(|f| f.name == n) + .unwrap() + .clone() + }; + let l = by("title").declared_limits(); + assert_eq!((l.min_length, l.max_length), (Some(2), Some(100))); + assert_eq!(by("title").effective_max_length(), Some(100)); + assert_eq!(by("notes").effective_max_length(), Some(50_000)); + let l = by("qty").declared_limits(); + assert_eq!((l.min, l.max), (Some(Bound::Int(0)), Some(Bound::Int(100)))); + let l = by("price").declared_limits(); + assert_eq!( + (l.min, l.max), + (Some(Bound::Int(-500)), Some(Bound::Int(100_000))) + ); + let l = by("rate").declared_limits(); + assert_eq!( + (l.min.unwrap().to_string(), l.max.unwrap().to_string()), + ("0.1000000".to_string(), "0.9000000".to_string()) + ); + let l = by("share").declared_limits(); + assert_eq!( + (l.min.unwrap().to_string(), l.max.unwrap().to_string()), + ("0.00".to_string(), "1.00".to_string()) + ); + // A field with no key has no declared limit and the default of its kind. + assert!(by("plain").declared_limits().is_empty()); + assert_eq!(by("plain").effective_max_length(), Some(DEFAULT_TEXT_MAX)); +} + +#[test] +fn a_bad_limit_is_refused_naming_the_field_and_the_key() { + // (the field's keys, a piece of the message). Each message names the model and the field. + let cases: &[(&str, &str)] = &[ + ("max_length = 0", "`max_length` must be at least 1"), + ("min_length = 0", "`min_length` must be at least 1"), + ( + "max_length = 10001", + "above the largest a text field may declare, 10000", + ), + ( + "kind = \"longtext\"\nmax_length = 1000001", + "above the largest a longtext field may declare, 1000000", + ), + ( + "min_length = 5\nmax_length = 4", + "`min_length` 5 is above `max_length` 4", + ), + ("min_length = 2049", "above the default maximum"), + ("max_length = -1", "invalid"), + ("max_length = \"100\"", "invalid"), + ("max_length = 1.5", "invalid"), + ("maxlength = 5", "unknown field"), + ( + "kind = \"number\"\nmax_length = 5", + "only valid for kind \"text\" or \"longtext\"", + ), + ( + "kind = \"date\"\nmin = 1", + "only valid for kind \"number\", \"money\" or \"decimal\"", + ), + ( + "min = 1", + "only valid for kind \"number\", \"money\" or \"decimal\"", + ), + ("kind = \"number\"\nmin = 0.5", "must be a whole number"), + ("kind = \"number\"\nmin = \"1\"", "must be a whole number"), + ( + "kind = \"decimal\"\nprecision = 10\nscale = 2\nmax = 1.5", + "a TOML float is not exact", + ), + ( + "kind = \"decimal\"\nprecision = 10\nscale = 2\nmax = \"1.234\"", + "is not a decimal that fits decimal(10, 2)", + ), + ( + "kind = \"decimal\"\nprecision = 4\nscale = 2\nmax = \"1000.00\"", + "is not a decimal that fits decimal(4, 2)", + ), + ( + "kind = \"decimal\"\nprecision = 10\nscale = 2\nmax = \"abc\"", + "is not a decimal", + ), + ( + "kind = \"number\"\nmin = 5\nmax = 4", + "`min` 5 is above `max` 4", + ), + ( + "kind = \"decimal\"\nprecision = 10\nscale = 2\nmin = \"0.11\"\nmax = \"0.10\"", + "`min` 0.11 is above `max` 0.10", + ), + ( + "computed = \"1 + 1\"\nkind = \"number\"\nmax = 5", + "computed or numbered", + ), + ]; + for (keys, want) in cases { + let text = pkg(&field("target", keys)); + let e = problem(&text).unwrap_or_else(|| panic!("{keys:?} should be refused")); + assert!(e.contains(want), "{keys:?}: wanted {want:?}, got {e}"); + // Where it happens: the error does not leave the person looking for the field. + if !e.contains("unknown field") && !e.contains("invalid") { + assert!( + e.contains("target"), + "{keys:?} does not name the field: {e}" + ); + } + } +} + +#[test] +fn the_edges_of_what_is_allowed_are_allowed() { + for keys in [ + "max_length = 1", + "max_length = 10000", + "min_length = 1\nmax_length = 1", + "kind = \"longtext\"\nmax_length = 1000000", + "kind = \"number\"\nmin = 5\nmax = 5", + "kind = \"number\"\nmin = -9223372036854775808\nmax = 9223372036854775807", + "kind = \"decimal\"\nprecision = 4\nscale = 2\nmin = \"-99.99\"\nmax = \"99.99\"", + ] { + assert_eq!(problem(&pkg(&field("target", keys))), None, "{keys}"); + } +} + +#[test] +fn a_default_of_a_migration_step_is_held_to_the_limits_of_its_field() { + let step = |value: &str| { + format!( + "\n[[migration]]\nfrom = \"<0.2\"\nop = \"set_default\"\nmodel = \"lim_doc\"\nfield = \"title\"\nvalue = {value}\n" + ) + }; + let with = |value: &str| { + pkg_with_core( + "^0.2.1", + "0.2.0", + &format!("{}{}", field("title", "max_length = 5"), step(value)), + ) + }; + assert_eq!(problem(&with("\"fits\"")), None); + let e = problem(&with("\"too long\"")).expect("a default of 8 characters into 5"); + assert!( + e.contains("outside the limits of title") && e.contains("at most 5 characters; this has 8"), + "{e}" + ); +} + +#[test] +fn a_float_default_into_a_number_or_money_field_is_refused() { + // A float is not a whole number: a write refuses it, and it would also pass the bounds of the + // field unseen (review of PR 33, F19). `set_default` takes the integer form only. + for kind in ["number", "money"] { + let with = |value: &str| { + pkg_with_core( + "^0.2.1", + "0.2.0", + &format!( + "{}\n[[migration]]\nfrom = \"<0.2\"\nop = \"set_default\"\nmodel = \"lim_doc\"\nfield = \"qty\"\nvalue = {value}\n", + field("qty", &format!("kind = \"{kind}\"\nmax = 100")) + ), + ) + }; + assert_eq!(problem(&with("50")), None, "{kind}"); + for float in ["1000.0", "50.5", "50.0"] { + let e = + problem(&with(float)).unwrap_or_else(|| panic!("{kind} {float} must be refused")); + assert!( + e.contains("does not fit the") && e.contains("a whole number"), + "{kind} {float}: {e}" + ); + } + // An integer above the bound is still refused by the limit. + let e = problem(&with("1000")).expect("1000 into max 100"); + assert!(e.contains("at most 100; this is 1000"), "{e}"); + } +} + +// ---- the version a package that uses limits declares -------------------------------------- + +/// D88 Decision 14: a feature that widens what the manifest parser accepts takes the next patch +/// number of the 0.2 line, and a package that uses it declares `^0.2.N`. This is the number of +/// the limits, written out here and not computed from `CORE_VERSION`, so that the core cannot +/// move it by accident. +const LIMITS_CORE_PATCH: u64 = 1; + +#[test] +fn core_version_requirement_kinds_limits() { + let n = LIMITS_CORE_PATCH; + let text = pkg_with_core( + &format!("^0.2.{n}"), + "0.1.0", + &field("title", "max_length = 100"), + ); + let m = Manifest::parse(&text).unwrap(); + let req = m.core_req(); + + // `^0.2.1` is satisfied by the core that has the keys and by the core that is running. + assert!(req.matches(&Version::new(0, 2, n))); + assert!(req.matches(&Version::parse(obop_types::CORE_VERSION).unwrap())); + assert!(Version::parse(obop_types::CORE_VERSION).unwrap() >= Version::new(0, 2, n)); + // It is not satisfied by the core before it, nor by 0.3.0. + assert!(!req.matches(&Version::new(0, 2, n - 1))); + assert!(!req.matches(&Version::new(0, 3, 0))); + + // The resolver turns that into a blocker on the older core, and into a plan on this one. + let market = |_: &str| Ok(None); + let world = |core: Version| World { + core, + builtin: BTreeMap::new(), + installed: BTreeMap::new(), + market: &market, + registered: &|_| false, + }; + let old = plan_install(&world(Version::new(0, 2, n - 1)), &m, "upload"); + assert!(!old.ok()); + assert_eq!( + old.blockers[0].message, + format!("lim 0.1.0 needs core ^0.2.{n}, you run 0.2.{}", n - 1) + ); + let current = plan_install( + &world(Version::parse(obop_types::CORE_VERSION).unwrap()), + &m, + "upload", + ); + assert!(current.ok(), "{:?}", current.blockers); + + // What the parser does not do, said plainly (D88 Decision 14): it does not check that the + // number a package declares is high enough for the keys it uses. A package with `^0.2` and + // a limit parses and gates like any other; the older core fails on the key at parse time. + let lax = pkg_with_core("^0.2", "0.1.0", &field("title", "max_length = 100")); + assert_eq!(problem(&lax), None); +} + +#[test] +fn a_bundled_package_that_uses_a_limit_must_declare_the_core_that_has_them() { + // The 23 bundled packages keep `^0.2` (OR-35 k). Any of them that gains a limit key must + // declare at least the core that has the keys, so that this test fails if one forgets. + let first = Version::new(0, 2, LIMITS_CORE_PATCH); + let before = Version::new(0, 2, LIMITS_CORE_PATCH - 1); + let mut checked = 0; + for listed in Bundled.list().unwrap() { + let m = Manifest::parse(&Bundled.fetch(&listed.id).unwrap()).unwrap(); + let module = m.to_module().unwrap(); + let uses = module + .models + .iter() + .flat_map(|d| &d.fields) + .any(|f| !f.declared_limits().is_empty()); + let req: Req = m.core_req(); + assert!( + req.matches(&Version::parse(obop_types::CORE_VERSION).unwrap()), + "{} does not run on the core", + listed.id + ); + if uses { + assert!( + req.matches(&first) && !req.matches(&before), + "{} uses field limits and declares {}", + listed.id, + m.module.requires_core + ); + } + checked += 1; + } + assert_eq!(checked, 23); +} + +// ---- upgrades ----------------------------------------------------------------------------- + +#[test] +fn a_changed_limit_is_a_listed_change_and_never_breaking() { + let old = Manifest::parse(&pkg(&field("title", "max_length = 100"))).unwrap(); + let kinds = |a: &Manifest, b: &Manifest| -> Vec<(String, bool)> { + diff(a, b) + .into_iter() + .map(|c| (format!("{}:{}", c.kind, c.detail), c.breaking)) + .collect() + }; + // Lowering a limit may leave a stored record above it; that is not breaking: such a record + // stays editable in its other fields (D90 Decision 6). + let lower = Manifest::parse(&pkg(&field("title", "max_length = 10"))).unwrap(); + let c = kinds(&old, &lower); + assert_eq!(c.len(), 1, "{c:?}"); + assert!(c[0].0.starts_with("field_limits:"), "{c:?}"); + assert!(c[0].0.contains("100") && c[0].0.contains("10"), "{c:?}"); + assert!(!c[0].1, "{c:?}"); + // Adding one, and dropping one, are listed too. + let none = Manifest::parse(&pkg(&field("title", ""))).unwrap(); + assert_eq!(kinds(&none, &old).len(), 1); + assert_eq!(kinds(&old, &none).len(), 1); + // The same limits are no change. + assert!(kinds(&old, &old).is_empty()); + // A number bound is listed as well. + let a = Manifest::parse(&pkg(&field("qty", "kind = \"number\"\nmax = 10"))).unwrap(); + let b = Manifest::parse(&pkg(&field("qty", "kind = \"number\"\nmax = 5"))).unwrap(); + assert_eq!(kinds(&a, &b).len(), 1); +} + +/// The detail of the one `field_limits` change from `old` to `new`. +fn limits_change(old: &str, new: &str) -> String { + let (a, b) = ( + Manifest::parse(&pkg(old)).unwrap(), + Manifest::parse(&pkg(new)).unwrap(), + ); + let c = diff(&a, &b); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].kind, "field_limits"); + assert!(!c[0].breaking); + c[0].detail.clone() +} + +#[test] +fn the_plan_shows_the_default_that_applies_when_a_limit_is_removed_and_which_way_it_goes() { + let title = |extra: &str| field("title", extra); + // Dropping a max_length does not make the field unlimited: the default of the kind applies. + // 5000 to 2048 tightens (review of PR 33, F2). + let d = limits_change(&title("max_length = 5000"), &title("")); + assert!( + d.contains("limits tighten from max_length 5000 to max_length 2048 (default)"), + "{d}" + ); + assert!( + d.contains("values already stored are not re-checked"), + "{d}" + ); + assert!(!d.contains("none"), "{d}"); + // A longtext has its own default. + let d = limits_change( + &field("notes", "kind = \"longtext\"\nmax_length = 300000"), + &field("notes", "kind = \"longtext\""), + ); + assert!( + d.contains("tighten from max_length 300000 to max_length 100000 (default)"), + "{d}" + ); + // Declaring a larger one than the default loosens; the old side shows the default it had. + let d = limits_change(&title(""), &title("max_length = 5000")); + assert!( + d.contains("limits loosen from max_length 2048 (default) to max_length 5000"), + "{d}" + ); + // A lower maximum or a new minimum tightens; a higher maximum or no minimum loosens. + assert!( + limits_change(&title("max_length = 100"), &title("max_length = 10")) + .contains("tighten from") + ); + assert!( + limits_change(&title(""), &title("min_length = 2")).contains( + "tighten from max_length 2048 (default) to min_length 2, max_length 2048 (default)" + ) + ); + assert!( + limits_change(&title("min_length = 2"), &title("")).contains( + "loosen from min_length 2, max_length 2048 (default) to max_length 2048 (default)" + ) + ); + // One limit tighter and another looser is said so. + let d = limits_change( + &title("max_length = 100"), + &title("min_length = 3\nmax_length = 200"), + ); + assert!(d.contains("limits tighten and loosen from"), "{d}"); + // Numbers: nothing is implied, so a removed bound is `none`, and the direction is read from + // the bounds, decimals included. + let qty = |extra: &str| field("qty", &format!("kind = \"number\"\n{extra}")); + assert!( + limits_change(&qty("max = 10"), &qty("max = 5")) + .contains("limits tighten from max 10 to max 5") + ); + assert!( + limits_change(&qty("max = 10"), &qty("")).contains("limits loosen from max 10 to none") + ); + assert!(limits_change(&qty(""), &qty("min = 0")).contains("limits tighten from none to min 0")); + let rate = |extra: &str| { + field( + "rate", + &format!("kind = \"decimal\"\nprecision = 8\nscale = 2\n{extra}"), + ) + }; + assert!( + limits_change(&rate("max = \"5.00\""), &rate("max = \"9.50\"")) + .contains("loosen from max 5.00 to max 9.50") + ); + assert!( + limits_change(&rate("min = \"1.00\""), &rate("min = \"0.50\"")) + .contains("loosen from min 1.00 to min 0.50") + ); + // What is held counts, not what is spelled: a declared max_length equal to the default is the + // default, and the same bound spelled as 5 and as "5.00" is the same bound. + let (a, b) = ( + Manifest::parse(&pkg(&title(&format!("max_length = {DEFAULT_TEXT_MAX}")))).unwrap(), + Manifest::parse(&pkg(&title(""))).unwrap(), + ); + assert!(diff(&a, &b).is_empty() && diff(&b, &a).is_empty()); + let (a, b) = ( + Manifest::parse(&pkg(&rate("max = 5"))).unwrap(), + Manifest::parse(&pkg(&rate("max = \"5.00\""))).unwrap(), + ); + assert!(diff(&a, &b).is_empty()); +} + +#[test] +fn an_installed_manifest_is_read_as_the_previous_core_read_it() { + // Closure check of PR 33: the float and the limits of a `set_default` value are refused for + // what comes in now (an install, an upgrade), never for a package that is already installed. + // The previous core let a float through for a number or money field and had no limits, and a + // server must still start with the module its data belongs to. + let step = |model_field: &str, value: &str| { + format!( + "\n[[migration]]\nfrom = \"<0.2\"\nop = \"set_default\"\nmodel = \"lim_doc\"\nfield = \"{model_field}\"\nvalue = {value}\n" + ) + }; + let long = format!("\"{}\"", "x".repeat(DEFAULT_TEXT_MAX as usize + 1)); + for (fields, step_text, refused) in [ + ( + field("qty", "kind = \"number\""), + step("qty", "0.0"), + "a whole number", + ), + ( + field("price", "kind = \"money\""), + step("price", "50.5"), + "a whole number", + ), + ( + field("title", ""), + step("title", &long), + "outside the limits of title", + ), + ( + field("qty", "kind = \"number\"\nmax = 100"), + step("qty", "1000"), + "at most 100; this is 1000", + ), + ] { + let text = pkg_with_core("^0.2", "0.2.0", &format!("{fields}{step_text}")); + let e = Manifest::parse(&text).expect_err("an upload or an upgrade refuses it"); + assert!(e.contains(refused), "{refused}: {e}"); + // Read as installed, it parses: only the float and the limits of a step are let through, + // so the module still builds. + let m = Manifest::parse_installed(&text) + .unwrap_or_else(|e| panic!("an installed copy must load ({refused}): {e}")); + m.to_module_installed().expect("and build"); + } + // What stays refused when installed is everything else: a step for a field that is not there, + // and a string for a number field. + let text = pkg_with_core( + "^0.2", + "0.2.0", + &format!( + "{}{}", + field("qty", "kind = \"number\""), + step("qty", "\"x\"") + ), + ); + let e = Manifest::parse_installed(&text).expect_err("not a number at all"); + assert!(e.contains("does not fit the number field qty"), "{e}"); +} diff --git a/crates/kernel/obop-pkg/src/manifest.rs b/crates/kernel/obop-pkg/src/manifest.rs index 925da8c2..a57a87fe 100644 --- a/crates/kernel/obop-pkg/src/manifest.rs +++ b/crates/kernel/obop-pkg/src/manifest.rs @@ -8,9 +8,9 @@ use obop_commands::{PublicRead, PublicReceipt, PublicSpec, PublicSubmit}; use obop_registry::labels::{LangLabels, ModelShape, Shape, Translations, check_labels}; use obop_registry::rules::{MAX_ASSERT_KEY, MAX_EFFECTS, SeqFormat, assert_key_ok}; use obop_registry::{ - Access, Assertion, CommandDef, Computed, Effect, EffectKind, FieldDef, FieldKind, - GENERATED_COMMANDS, History, Lit, Locator, MATURITIES, MODULE_CATEGORIES, ModelDef, ModelExt, - Module, ModuleMeta, Reset, SequenceSpec, ShowsOption, Src, Target, Unique, + Access, Assertion, Bound, CommandDef, Computed, Effect, EffectKind, FieldDef, FieldKind, + GENERATED_COMMANDS, History, Limits, Lit, Locator, MATURITIES, MODULE_CATEGORIES, ModelDef, + ModelExt, Module, ModuleMeta, Reset, SequenceSpec, ShowsOption, Src, Target, Unique, check_installed_module, check_module, intern, }; use obop_types::DataClass; @@ -284,14 +284,131 @@ pub struct FieldM { /// browser evaluates) on the same record. The label shows only while it holds, because the /// field may also hold free text. pub when: Option, + /// `kind = "text"` or `"longtext"` (D90): the most characters (Unicode scalar values, not + /// bytes) a written value has, 1 to 10,000 for `text` and 1 to 1,000,000 for `longtext`. + /// Without it the field has the default of its kind (2,048 and 100,000). + pub max_length: Option, + /// `kind = "text"` or `"longtext"` (D90): the fewest characters a written value has, at + /// least 1 and not above `max_length`. A value is counted as stored: nothing is trimmed. + pub min_length: Option, + /// `kind = "number"`, `"money"` or `"decimal"` (D90): the smallest value, inclusive. A whole + /// number (a TOML integer) for `number` and `money`; for `decimal` a string (`"0.10"`) or a TOML + /// integer with at most `scale` fractional digits (fewer are padded to the scale) that fits the + /// precision. A TOML float is refused because it is not exact. + pub min: Option, + /// As `min`: the largest value, inclusive. + pub max: Option, } -/// Whether `v` is a value the field can hold, for `set_default`. -fn default_matches(f: &FieldM, v: &toml::Value) -> Result<(), String> { +/// The registry kind of a manifest field (`sequence` is a text field that is numbered). +fn kind_of(f: &FieldM) -> FieldKind { + match f.kind.as_str() { + "longtext" => FieldKind::LongText, + "number" => FieldKind::Number, + "money" => FieldKind::Money, + "date" => FieldKind::Date, + "datetime" => FieldKind::Datetime, + "decimal" => FieldKind::Decimal { + precision: f.precision.unwrap_or(0) as u8, + scale: f.scale.unwrap_or(0) as u8, + round: f.round.as_deref().and_then(obop_values::Round::parse), + }, + "bool" => FieldKind::Bool, + "select" => FieldKind::Select( + f.options + .iter() + .map(|o| intern(o)) + .collect::>() + .into(), + ), + "ref" => FieldKind::Ref(intern(f.to.as_deref().unwrap_or(""))), + "image" => FieldKind::Image(f.max_bytes.unwrap_or(0)), + "file" => FieldKind::File(f.max_bytes.unwrap_or(0)), + _ => FieldKind::Text, + } +} + +/// One bound of `min` or `max` (D90) as the registry holds it. A `number` or `money` bound is a +/// TOML integer; a `decimal` bound is a string (or an integer) that fits the field's precision, +/// with at most `scale` fractional digits, and is kept at the scale of the field. A float is +/// refused: it is not exact. +fn bound_of(key: &str, v: &toml::Value, f: &FieldM) -> Result { + use toml::Value as T; + match (f.kind.as_str(), v) { + ("number" | "money", T::Integer(n)) => Ok(Bound::Int(*n)), + ("number" | "money", T::Float(_)) => Err(format!( + "`{key}` must be a whole number for kind {:?}, not a float", + f.kind + )), + ("number" | "money", _) => Err(format!( + "`{key}` must be a whole number (a TOML integer without quotes) for kind {:?}", + f.kind + )), + ("decimal", T::Float(_)) => Err(format!( + "`{key}` of a decimal field must be written as a string such as \"0.10\": a TOML float is not exact" + )), + ("decimal", T::String(_) | T::Integer(_)) => { + let text = match v { + T::String(s) => s.clone(), + other => other.to_string(), + }; + let (Some(p), Some(sc)) = (f.precision, f.scale) else { + return Err(format!( + "`{key}` needs the precision and scale of the decimal" + )); + }; + obop_values::Decimal::parse_for(&text, p as u8, sc as u8, None) + .map(Bound::Dec) + .map_err(|e| { + format!( + "`{key}` {text:?} is not a decimal that fits decimal({p}, {sc}) with at most {sc} digits after the point: {e}" + ) + }) + } + ("decimal", _) => Err(format!( + "`{key}` of a decimal field must be a string such as \"0.10\"" + )), + _ => Err(format!( + "`min` and `max` are only valid for kind \"number\", \"money\" or \"decimal\", not {:?}", + f.kind + )), + } +} + +/// The limits a field declares (D90), parsed; their meaning (which kind they fit, `min` above +/// `max`, a length above the cap) is the D41 gate's, which `field_def` runs on the result. +fn limits_of(f: &FieldM) -> Result { + Ok(Limits { + min_length: f.min_length, + max_length: f.max_length, + min: f.min.as_ref().map(|v| bound_of("min", v, f)).transpose()?, + max: f.max.as_ref().map(|v| bound_of("max", v, f)).transpose()?, + }) +} + +/// A field of `f`'s kind and limits, to hold a value against (`set_default`). +pub(crate) fn probe(f: &FieldM) -> Result { + Ok( + FieldDef::new(intern(&f.name), "", DataClass::None, false, "") + .kind(kind_of(f)) + .with_limits(limits_of(f)?), + ) +} + +/// Whether `v` is a value the field can hold, for `set_default`. `installed` is true when the +/// manifest is the text of a package that is already installed: the step was checked, or not +/// needed, when that copy went in, and it is read as the previous core read it (a float for a +/// number or money field is let through, no limit applies), so that a server still starts with +/// the module its data belongs to. Only an install or an upgrade refuses what D90 refuses. +fn default_matches(f: &FieldM, v: &toml::Value, installed: bool) -> Result<(), String> { use toml::Value as T; let ok = match (f.kind.as_str(), v) { ("text" | "longtext" | "date" | "ref", T::String(_)) => true, - ("number" | "money", T::Integer(_) | T::Float(_)) => true, + // A whole number: a float is not stored by a write either, and it would also skip the + // bounds of the field (D90). + ("number" | "money", T::Integer(_)) => true, + // The previous core let a float through here, so an installed package may carry one. + ("number" | "money", T::Float(_)) if installed => true, ("datetime", T::String(s)) => obop_values::Instant::parse(s).is_ok(), // A decimal default is a string ("12.50") or a whole number; a TOML float is not exact. ("decimal", T::String(s)) => match (f.precision, f.scale) { @@ -312,7 +429,7 @@ fn default_matches(f: &FieldM, v: &toml::Value) -> Result<(), String> { _ => false, }; if ok { - Ok(()) + if installed { Ok(()) } else { limits_hold(f, v) } } else { Err(format!( "the value does not fit the {} field {}{}", @@ -322,6 +439,8 @@ fn default_matches(f: &FieldM, v: &toml::Value) -> Result<(), String> { format!(" (options: {})", f.options.join(", ")) } else if matches!(f.kind.as_str(), "image" | "file" | "sequence") { " (it cannot be given a default)".to_string() + } else if matches!(f.kind.as_str(), "number" | "money") { + " (a whole number, written without a decimal point)".to_string() } else { String::new() } @@ -329,6 +448,41 @@ fn default_matches(f: &FieldM, v: &toml::Value) -> Result<(), String> { } } +/// A `set_default` value is held to the limits of its field like any write (D90): a step cannot +/// store what the field would refuse. A field whose limits do not parse is reported where its +/// limits are read, not here. +fn limits_hold(f: &FieldM, v: &toml::Value) -> Result<(), String> { + use toml::Value as T; + let Ok(d) = probe(f) else { return Ok(()) }; + let refused = |b: obop_registry::Breach, shown: &str| { + format!( + "the value is outside the limits of {}: it must be {}", + f.name, + b.detail(shown) + ) + }; + match (f.kind.as_str(), v) { + ("text" | "longtext", T::String(s)) => d.check_text(s).map_err(|b| refused(b, "")), + ("number" | "money", T::Integer(n)) => { + d.check_int(*n).map_err(|b| refused(b, &n.to_string())) + } + ("decimal", T::String(_) | T::Integer(_)) => { + let (Some(p), Some(sc)) = (f.precision, f.scale) else { + return Ok(()); + }; + let text = match v { + T::String(s) => s.clone(), + other => other.to_string(), + }; + match obop_values::Decimal::parse_for(&text, p as u8, sc as u8, None) { + Ok(x) => d.check_decimal(x).map_err(|b| refused(b, &x.to_string())), + Err(_) => Ok(()), + } + } + _ => Ok(()), + } +} + fn default_kind() -> String { "text".into() } @@ -672,7 +826,7 @@ impl Manifest { self.build_models()?; self.public_specs()?; check_labels(&self.labels, &self.shape())?; - self.validate_migrations()?; + self.validate_migrations(installed)?; Ok(()) } @@ -714,7 +868,7 @@ impl Manifest { /// `[[migration]]` (D63): each step names a model and field of this package and says what to /// do to the records of an older version. The steps are checked against the shape of this /// version, so a step that could never run is refused when the package is read. - fn validate_migrations(&self) -> Result<(), String> { + fn validate_migrations(&self, installed: bool) -> Result<(), String> { if self.migration.len() > 100 { return Err("a package may declare at most 100 migration steps".into()); } @@ -765,7 +919,7 @@ impl Manifest { let Some(v) = &st.value else { return Err(format!("{at}: set_default needs `value`")); }; - default_matches(f, v).map_err(|e| format!("{at}: {e}"))?; + default_matches(f, v, installed).map_err(|e| format!("{at}: {e}"))?; if st.to.is_some() || st.confirm { return Err(format!("{at}: set_default takes only `value`")); } @@ -1555,30 +1709,8 @@ impl Manifest { fn field_def(&self, m: &ModelM, f: &FieldM) -> Result { let at = format!("{}.{}", m.name, f.name); - let kind = match f.kind.as_str() { - "longtext" => FieldKind::LongText, - "number" => FieldKind::Number, - "money" => FieldKind::Money, - "date" => FieldKind::Date, - "datetime" => FieldKind::Datetime, - "decimal" => FieldKind::Decimal { - precision: f.precision.unwrap_or(0) as u8, - scale: f.scale.unwrap_or(0) as u8, - round: f.round.as_deref().and_then(obop_values::Round::parse), - }, - "bool" => FieldKind::Bool, - "select" => FieldKind::Select( - f.options - .iter() - .map(|o| intern(o)) - .collect::>() - .into(), - ), - "ref" => FieldKind::Ref(intern(f.to.as_deref().unwrap_or(""))), - "image" => FieldKind::Image(f.max_bytes.unwrap_or(0)), - "file" => FieldKind::File(f.max_bytes.unwrap_or(0)), - _ => FieldKind::Text, - }; + let kind = kind_of(f); + let limits = limits_of(f).map_err(|e| format!("{at}: {e}"))?; let sequence = if f.kind == "sequence" { let format = SeqFormat::parse(f.format.as_deref().unwrap_or_default()) .map_err(|e| format!("{at}: format: {e}"))?; @@ -1619,7 +1751,7 @@ impl Manifest { )), None => None, }; - Ok(FieldDef { + let def = FieldDef { name: intern(&f.name), description: intern(&f.description), class: DataClass::parse(&f.class), @@ -1633,7 +1765,14 @@ impl Manifest { }), sequence, shows_option, - }) + limits: (!limits.is_empty()).then(|| std::sync::Arc::new(limits)), + }; + // The limits are checked here as well as by the gate, so the error of a bad one names the + // file's field before anything else is parsed (D90). + if let Some(problem) = obop_registry::limits::problems(&def).into_iter().next() { + return Err(format!("{at}: {problem}")); + } + Ok(def) } /// The models as registry data, rules included. Syntax errors in expressions, formats and @@ -1987,12 +2126,12 @@ write_group = "fleet.manager" let t = at("kind = \"decimal\"\n precision = 6\n scale = 2"); let f = Manifest::parse(&t).unwrap().model[0].field[0].clone(); let v = |s: &str| toml::from_str::(s).unwrap()["v"].clone(); - let ok = |x: &str| default_matches(&f, &v(&format!("v = {x}"))).is_ok(); + let ok = |x: &str| default_matches(&f, &v(&format!("v = {x}")), false).is_ok(); assert!(ok("\"12.5\"") && ok("12")); assert!(!ok("12.5") && !ok("\"1.234\"") && !ok("\"1e3\"") && !ok("true") && !ok("1234567")); let t = at("kind = \"datetime\""); let f = Manifest::parse(&t).unwrap().model[0].field[0].clone(); - let ok = |x: &str| default_matches(&f, &v(&format!("v = {x}"))).is_ok(); + let ok = |x: &str| default_matches(&f, &v(&format!("v = {x}")), false).is_ok(); assert!(ok("\"2026-03-29T01:30:00Z\"") && !ok("\"2026-03-29\"") && !ok("5")); } diff --git a/crates/kernel/obop-pkg/src/migrate.rs b/crates/kernel/obop-pkg/src/migrate.rs index 3b6bfaa0..a29b5887 100644 --- a/crates/kernel/obop-pkg/src/migrate.rs +++ b/crates/kernel/obop-pkg/src/migrate.rs @@ -22,17 +22,19 @@ //! the whole batch fail cleanly (and it is retried against the fresh data) instead of losing the //! edit. Everything here is pure except `build_ops`, which reads records. -use crate::manifest::{FieldM, Manifest, MigrationM}; +use crate::manifest::{FieldM, Manifest, MigrationM, probe}; use crate::semver::{Req, Version}; +use obop_registry::Bound; use obop_storage::{Backend, Fields, Op}; use serde_json::{Value as J, json}; +use std::cmp::Ordering; use std::collections::BTreeMap; /// One thing that differs between two versions of a package. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Change { /// `model_added`, `model_removed`, `field_added`, `field_removed`, `field_retyped`, - /// `field_reshaped`, `field_required`, `option_added`, `option_removed`, `action_added`, + /// `field_reshaped`, `field_limits`, `field_required`, `option_added`, `option_removed`, `action_added`, /// `action_removed`. pub kind: &'static str, pub model: String, @@ -44,6 +46,156 @@ pub struct Change { pub resolved_by: Option, } +/// The limits a field has (D90), declared and implied, for the plan: a text or longtext field that +/// declares no `max_length` still has the default of its kind, so "none" would read as unlimited. +#[derive(Debug, Clone)] +struct Held { + min_length: Option, + /// The effective maximum (the declared one, else the default of the kind) and whether it is + /// the default. + max_length: Option<(u32, bool)>, + min: Option, + max: Option, +} + +impl Held { + fn of(f: &FieldM) -> Self { + let Ok(def) = probe(f) else { + // Not parseable: the install gate refuses it anyway; show what is declared. + return Self { + min_length: f.min_length, + max_length: f.max_length.map(|n| (n, false)), + min: None, + max: None, + }; + }; + // A computed or numbered field is never written: it has no default length. + let written = f.computed.is_none() && f.kind != "sequence"; + let l = def.declared_limits(); + Self { + min_length: l.min_length, + max_length: if written { + def.effective_max_length() + .map(|n| (n, l.max_length.is_none())) + } else { + l.max_length.map(|n| (n, false)) + }, + min: l.min, + max: l.max, + } + } + + /// Whether both hold a value to the same limits. A declared `max_length` that equals the default + /// holds it to the same one. + fn same(&self, other: &Self) -> bool { + ( + self.min_length, + self.max_length.map(|m| m.0), + self.min, + self.max, + ) == ( + other.min_length, + other.max_length.map(|m| m.0), + other.min, + other.max, + ) + } + + /// Nothing is held: no length (a number, a date, ...), no bound. + fn is_none(&self) -> bool { + self.min_length.is_none() + && self.max_length.is_none() + && self.min.is_none() + && self.max.is_none() + } + + fn text(&self) -> String { + if self.is_none() { + return "none".into(); + } + let mut parts = Vec::new(); + if let Some(n) = self.min_length { + parts.push(format!("min_length {n}")); + } + match self.max_length { + Some((n, true)) => parts.push(format!("max_length {n} (default)")), + Some((n, false)) => parts.push(format!("max_length {n}")), + None => {} + } + if let Some(b) = self.min { + parts.push(format!("min {b}")); + } + if let Some(b) = self.max { + parts.push(format!("max {b}")); + } + parts.join(", ") + } + + /// Whether `new` holds a value to more than `self` does, to less, or both (one limit tighter + /// and another looser), comparing what is held and not what is spelled. + fn change_to(&self, new: &Self) -> &'static str { + let (mut tighter, mut looser) = (false, false); + let mut note = |o: Ordering| match o { + Ordering::Greater => tighter = true, + Ordering::Less => looser = true, + Ordering::Equal => {} + }; + note(tighter_upper( + self.max_length.map(|m| m.0), + new.max_length.map(|m| m.0), + |a, b| Some(a.cmp(&b)), + )); + note(tighter_lower(self.min_length, new.min_length, |a, b| { + Some(a.cmp(&b)) + })); + note(tighter_upper(self.max, new.max, cmp_bound)); + note(tighter_lower(self.min, new.min, cmp_bound)); + match (tighter, looser) { + (true, false) => "tighten", + (false, true) => "loosen", + (true, true) => "tighten and loosen", + (false, false) => "change", + } + } +} + +/// `Greater` when `new` is the tighter of two upper limits (`None` is no limit), `Less` when it +/// is the looser. +fn tighter_upper( + old: Option, + new: Option, + cmp: impl Fn(T, T) -> Option, +) -> Ordering { + match (old, new) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(o), Some(n)) => cmp(o, n).unwrap_or(Ordering::Equal), + } +} + +/// As [`tighter_upper`] for lower limits: the higher one is the tighter. +fn tighter_lower( + old: Option, + new: Option, + cmp: impl Fn(T, T) -> Option, +) -> Ordering { + match (old, new) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(o), Some(n)) => cmp(o, n).map_or(Ordering::Equal, Ordering::reverse), + } +} + +fn cmp_bound(a: Bound, b: Bound) -> Option { + match (a, b) { + (Bound::Int(x), Bound::Int(y)) => Some(x.cmp(&y)), + (Bound::Dec(x), Bound::Dec(y)) => Some(x.compare(y)), + _ => None, + } +} + /// The structural difference between the installed manifest and the new one. pub fn diff(old: &Manifest, new: &Manifest) -> Vec { let mut out = Vec::new(); @@ -121,6 +273,26 @@ pub fn diff(old: &Manifest, new: &Manifest) -> Vec { of.scale != nf.scale || nf.precision < of.precision, )); } + // Limits (D90) only tighten or loosen what is written from now on; stored + // values are not re-checked, so this is shown and never blocks. + // What is held counts, not what is spelled: a text field that drops its + // `max_length` is held to the default of its kind from then on. + let (held_old, held_new) = (Held::of(of), Held::of(nf)); + if !held_old.same(&held_new) { + out.push(ch( + "field_limits", + &nm.name, + Some(&nf.name), + format!( + "{}: limits {} from {} to {}; values already stored are not re-checked", + nf.name, + held_old.change_to(&held_new), + held_old.text(), + held_new.text() + ), + false, + )); + } if nf.required && !of.required && nf.computed.is_none() { out.push(ch( "field_required", diff --git a/crates/kernel/obop-registry/src/lib.rs b/crates/kernel/obop-registry/src/lib.rs index 391d51a5..3779d42d 100644 --- a/crates/kernel/obop-registry/src/lib.rs +++ b/crates/kernel/obop-registry/src/lib.rs @@ -9,12 +9,17 @@ use std::sync::{Arc, Mutex, OnceLock}; pub mod expr; pub mod ext; pub mod labels; +pub mod limits; pub mod rules; pub mod template; pub use ext::{ CondOp, Exit, Guard, History, IndexCond, IndexDef, IndexKind, LockDate, ModelExt, StateSet, }; pub use labels::Translations; +pub use limits::{ + Bound, Breach, DEFAULT_LONGTEXT_MAX, DEFAULT_TEXT_MAX, LONGTEXT_MAX_CAP, Limits, TEXT_MAX_CAP, + char_count, limits_note, +}; pub use rules::{ Access, Assertion, Computed, Effect, EffectKind, Lit, Locator, Reset, SeqFormat, SequenceSpec, ShowsOption, ShowsWhen, Src, Target, Unique, @@ -254,6 +259,9 @@ pub struct FieldDef { /// A text field whose value is shown with the label of the option it equals in a select /// field (D52 amendment). Display only. pub shows_option: Option>, + /// Length and range limits (D90); `None`: none declared (a text field still has the default + /// length, see [`FieldDef::effective_max_length`]). + pub limits: Option>, } #[derive(Debug, Clone)] @@ -346,6 +354,7 @@ impl FieldDef { unique: None, sequence: None, shows_option: None, + limits: None, } } @@ -859,6 +868,10 @@ fn check_basics(m: &Module, reserved: bool) -> Vec { { bad(fp.clone(), &problem); } + // Length and range limits (D90), also for a package that is registered again. + for problem in limits::problems(f) { + bad(fp.clone(), &problem); + } match &f.kind { FieldKind::Select(o) if o.is_empty() => { bad(fp.clone(), "select field has no options") @@ -990,6 +1003,9 @@ fn check_history_setting(model: &ModelDef, mp: &str, bad: &mut impl FnMut(String } } +#[cfg(test)] +mod limits_tests; + #[cfg(test)] mod intern_tests { #[test] @@ -1018,6 +1034,7 @@ mod tests { unique: None, sequence: None, shows_option: None, + limits: None, } } diff --git a/crates/kernel/obop-registry/src/limits.rs b/crates/kernel/obop-registry/src/limits.rs new file mode 100644 index 00000000..2c52870c --- /dev/null +++ b/crates/kernel/obop-registry/src/limits.rs @@ -0,0 +1,412 @@ +//! Length and range limits of fields (D90): `max_length` and `min_length` on `text` and +//! `longtext`, `min` and `max` on `number`, `money` and `decimal`. +//! +//! The limits are metadata of the field, so every surface (REST, MCP, CLI, the back office, the +//! OpenAPI document, `GET /api/meta`) reads them from here and the command layer checks them on +//! every write that checks field kinds. They are checked on the value that is written; a value +//! that is already stored is never refused for being outside a limit that was set later (D90 +//! Decision 6). +//! +//! # Lengths count Unicode scalar values +//! +//! A length is the number of code points (`str::chars`), not bytes: `å`, `ä`, `ö` and an emoji +//! such as U+1F600 count 1 each. A letter written with a combining mark and an emoji built from +//! several code points (a flag, a family) count as many as they have; the limit is not about what +//! a person sees as one character. Nothing is trimmed or normalised before counting. +//! +//! # The default and the cap +//! +//! A `text` or `longtext` field that declares no `max_length` has the default of its kind +//! ([`DEFAULT_TEXT_MAX`], [`DEFAULT_LONGTEXT_MAX`]); a field may declare its own up to the hard +//! cap of its kind ([`TEXT_MAX_CAP`], [`LONGTEXT_MAX_CAP`]). All four are named here and nowhere +//! else. + +use crate::{FieldDef, FieldKind}; +use obop_values::Decimal; +use std::cmp::Ordering; +use std::fmt; +use std::sync::Arc; + +/// The longest value, in characters, a `text` field accepts when it declares no `max_length`. +/// +/// A round number with a margin over the longest text value measured in the bundled seed data +/// (140 characters); names, titles, addresses and codes are far shorter. 2,048 is also the +/// conservative ceiling for a URL, the longest ordinary single-line value: sitemaps.org asks for +/// fewer than 2,048 characters and old Internet Explorer stopped near it (modern browsers accept +/// far more), and it is this project's own limit for an OAuth redirect URI (`obop-oauth`, +/// `redirect.rs`). A field that holds a list in one line (a comma-separated list of people) +/// declares its own `max_length`. +pub const DEFAULT_TEXT_MAX: u32 = 2_048; + +/// The longest value, in characters, a `longtext` field accepts when it declares no +/// `max_length`. +/// +/// A round number with a margin over the longest longtext value measured in the bundled seed +/// data (2,040 characters), about fifty printed pages. Unofficial third-party analogies, for +/// scale only: GitHub refuses an issue body above 65,536 characters (its error message; it is +/// not documented), and mail clients are reported to clip HTML mail near 102,400 bytes. +pub const DEFAULT_LONGTEXT_MAX: u32 = 100_000; + +/// The largest `max_length` a `text` field may declare. +pub const TEXT_MAX_CAP: u32 = 10_000; + +/// The largest `max_length` a `longtext` field may declare. The request body limit of the HTTP +/// server (2 MiB) still applies first, so text of three or four bytes a character reaches the +/// body limit before it reaches this number. +pub const LONGTEXT_MAX_CAP: u32 = 1_000_000; + +/// A bound of `min` or `max`: a whole number for `number` and `money`, an exact decimal (at the +/// scale of the field) for `decimal`. Never a float. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bound { + Int(i64), + Dec(Decimal), +} + +impl fmt::Display for Bound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Int(n) => write!(f, "{n}"), + Self::Dec(d) => write!(f, "{d}"), + } + } +} + +/// The limits a field declares (D90). Every part is optional; a field without any has none +/// (apart from the default length of a text field). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Limits { + /// `text` and `longtext`: the fewest characters a written value has. + pub min_length: Option, + /// `text` and `longtext`: the most characters a written value has. + pub max_length: Option, + /// `number`, `money` and `decimal`: the smallest value, inclusive. + pub min: Option, + /// `number`, `money` and `decimal`: the largest value, inclusive. + pub max: Option, +} + +impl Limits { + /// Whether nothing is declared. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +/// How a written value breaks a limit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Breach { + /// Fewer characters than `min_length`; `have` is the number of characters written. + TooShort { min: u32, have: usize }, + /// More characters than the effective `max_length`. + TooLong { max: u32, have: usize }, + /// Below `min`. + Below(Bound), + /// Above `max`. + Above(Bound), +} + +impl Breach { + /// What the value must be, with nothing about the value itself: `at most 2000 characters`, + /// `at least 0`. This is what a refusal says when the caller may not be told how long or how + /// large the value is (an agent holding a masked token; a value an action copied from a + /// field the caller cannot read). + pub fn requirement(&self) -> String { + let chars = |n: u32| format!("{n} character{}", if n == 1 { "" } else { "s" }); + match self { + Self::TooShort { min, .. } => format!("at least {}", chars(*min)), + Self::TooLong { max, .. } => format!("at most {}", chars(*max)), + Self::Below(b) => format!("at least {b}"), + Self::Above(b) => format!("at most {b}"), + } + } + + /// What the value must be and what it was: `at most 2000 characters; this has 2001`. `value` is + /// the written number, for the two bounds. + pub fn detail(&self, value: &str) -> String { + let req = self.requirement(); + match self { + Self::TooShort { have, .. } | Self::TooLong { have, .. } => { + format!("{req}; this has {have}") + } + Self::Below(_) | Self::Above(_) => format!("{req}; this is {value}"), + } + } + + /// `model.field must be ...`, naming the field and the limit and saying what was written. + pub fn message(&self, model: &str, field: &str, value: &str) -> String { + format!("{model}.{field} must be {}", self.detail(value)) + } + + /// As [`Breach::message`] without the length or the value that was written: `model.field must + /// be at most 10 characters`. + pub fn message_without_value(&self, model: &str, field: &str) -> String { + format!("{model}.{field} must be {}", self.requirement()) + } +} + +/// The number of characters (Unicode scalar values) of `s`. +pub fn char_count(s: &str) -> usize { + s.chars().count() +} + +impl FieldDef { + /// This field with these limits (none when `limits` is empty). + pub fn with_limits(mut self, limits: Limits) -> Self { + self.limits = (!limits.is_empty()).then(|| Arc::new(limits)); + self + } + + /// The limits as declared, with no default filled in. + pub fn declared_limits(&self) -> Limits { + self.limits.as_deref().copied().unwrap_or_default() + } + + /// The most characters a value of this field has: the declared `max_length`, else the default + /// of the kind. `None` for every kind but `text` and `longtext`, and for a computed or numbered + /// field, which is never written. + pub fn effective_max_length(&self) -> Option { + if self.computed.is_some() || self.sequence.is_some() { + return None; + } + let default = match self.kind { + FieldKind::Text => DEFAULT_TEXT_MAX, + FieldKind::LongText => DEFAULT_LONGTEXT_MAX, + _ => return None, + }; + Some(self.declared_limits().max_length.unwrap_or(default)) + } + + /// The fewest characters a value of this field has, if it declares a `min_length`. + pub fn effective_min_length(&self) -> Option { + if self.computed.is_some() || self.sequence.is_some() { + return None; + } + match self.kind { + FieldKind::Text | FieldKind::LongText => self.declared_limits().min_length, + _ => None, + } + } + + /// The declared `min` of a number, money or decimal field. + pub fn lower_bound(&self) -> Option { + self.declared_limits().min + } + + /// The declared `max` of a number, money or decimal field. + pub fn upper_bound(&self) -> Option { + self.declared_limits().max + } + + /// Whether `s` is a value this field accepts, as far as its length is concerned. + pub fn check_text(&self, s: &str) -> Result<(), Breach> { + let max = self.effective_max_length(); + let min = self.effective_min_length(); + if max.is_none() && min.is_none() { + return Ok(()); + } + // A string of `n` bytes has at most `n` and at least `n / 4` (rounded up) characters, + // so most values are decided without counting them. + let bytes = s.len(); + let sure_max = max.is_none_or(|m| bytes <= m as usize); + let sure_min = min.is_none_or(|m| bytes.div_ceil(4) >= m as usize); + if sure_max && sure_min { + return Ok(()); + } + let have = char_count(s); + if let Some(m) = max + && have > m as usize + { + return Err(Breach::TooLong { max: m, have }); + } + if let Some(m) = min + && have < m as usize + { + return Err(Breach::TooShort { min: m, have }); + } + Ok(()) + } + + /// Whether `n` is a value this field accepts, as far as `min` and `max` are concerned (a + /// `number` or `money` field). + pub fn check_int(&self, n: i64) -> Result<(), Breach> { + let l = self.declared_limits(); + if let Some(Bound::Int(m)) = l.min + && n < m + { + return Err(Breach::Below(Bound::Int(m))); + } + if let Some(Bound::Int(m)) = l.max + && n > m + { + return Err(Breach::Above(Bound::Int(m))); + } + Ok(()) + } + + /// As [`FieldDef::check_int`], for a `decimal` field. The comparison is exact (`Decimal` + /// compares by value whatever the scales), never through a float. + pub fn check_decimal(&self, d: Decimal) -> Result<(), Breach> { + let l = self.declared_limits(); + if let Some(Bound::Dec(m)) = l.min + && d.compare(m) == Ordering::Less + { + return Err(Breach::Below(Bound::Dec(m))); + } + if let Some(Bound::Dec(m)) = l.max + && d.compare(m) == Ordering::Greater + { + return Err(Breach::Above(Bound::Dec(m))); + } + Ok(()) + } + + /// The limits this field declares, as a sentence for descriptions (OpenAPI, MCP): empty when it + /// declares none, so the default of the kind (which the schema keywords and `/api/meta` state) + /// adds no words to the description of every text field. `(at most 100 characters)`, `(from 1 + /// character to 2000 characters)`, `(from 0 to 100)`. + pub fn limits_note(&self) -> String { + let l = self.declared_limits(); + limits_note(l.min_length, l.max_length, l.min, l.max) + } +} + +/// The text behind [`FieldDef::limits_note`], for callers that hold the limits as plain values. +pub fn limits_note( + min_length: Option, + max_length: Option, + min: Option, + max: Option, +) -> String { + let chars = |n: u32| format!("{n} character{}", if n == 1 { "" } else { "s" }); + match (min_length, max_length, min, max) { + (None, None, None, None) => String::new(), + (Some(lo), Some(hi), _, _) => format!(" (from {} to {})", chars(lo), chars(hi)), + (Some(lo), None, _, _) => format!(" (at least {})", chars(lo)), + (None, Some(hi), _, _) => format!(" (at most {})", chars(hi)), + (_, _, Some(lo), Some(hi)) => format!(" (from {lo} to {hi})"), + (_, _, Some(lo), None) => format!(" (at least {lo})"), + (_, _, None, Some(hi)) => format!(" (at most {hi})"), + } +} + +/// What the install gate (D41) says about the limits of one field: one sentence per problem, +/// each naming the key. `kind` and the digits of a decimal come from the field itself. +pub fn problems(f: &FieldDef) -> Vec { + let l = f.declared_limits(); + let mut out = Vec::new(); + if l.is_empty() { + return out; + } + let is_text = matches!(f.kind, FieldKind::Text | FieldKind::LongText); + let is_num = matches!( + f.kind, + FieldKind::Number | FieldKind::Money | FieldKind::Decimal { .. } + ); + if f.computed.is_some() || f.sequence.is_some() { + out.push( + "a computed or numbered field is never written, so it takes no limits (max_length, min_length, min, max)" + .into(), + ); + return out; + } + if !is_text && (l.max_length.is_some() || l.min_length.is_some()) { + out.push(format!( + "`max_length` and `min_length` are only valid for kind \"text\" or \"longtext\", not {:?}", + f.kind.name() + )); + } + if !is_num && (l.min.is_some() || l.max.is_some()) { + out.push(format!( + "`min` and `max` are only valid for kind \"number\", \"money\" or \"decimal\", not {:?}", + f.kind.name() + )); + } + if is_text { + let cap = if f.kind == FieldKind::LongText { + LONGTEXT_MAX_CAP + } else { + TEXT_MAX_CAP + }; + let default = if f.kind == FieldKind::LongText { + DEFAULT_LONGTEXT_MAX + } else { + DEFAULT_TEXT_MAX + }; + if l.max_length == Some(0) { + out.push("`max_length` must be at least 1".into()); + } + if l.min_length == Some(0) { + out.push("`min_length` must be at least 1 (a minimum of 0 limits nothing)".into()); + } + if let Some(m) = l.max_length + && m > cap + { + out.push(format!( + "`max_length` {m} is above the largest a {} field may declare, {cap}", + f.kind.name() + )); + } + match (l.min_length, l.max_length) { + (Some(lo), Some(hi)) if lo > hi => out.push(format!( + "`min_length` {lo} is above `max_length` {hi}, so no value fits" + )), + // No `max_length`, so the default applies. Above the cap no `max_length` can help: + // say so, and where more room is (a longtext field), rather than advise a number + // the gate would refuse next. + (Some(lo), None) if lo > cap => out.push(format!( + "`min_length` {lo} is above {cap}, the most a {} field can hold{}", + f.kind.name(), + if f.kind == FieldKind::LongText { + String::new() + } else { + format!("; use a longtext field for more (up to {LONGTEXT_MAX_CAP})") + } + )), + (Some(lo), None) if lo > default => out.push(format!( + "`min_length` {lo} is above the default maximum of a {} field ({default}); declare a `max_length` of at least {lo}, at most {cap}{}", + f.kind.name(), + if f.kind == FieldKind::LongText { + String::new() + } else { + format!(" (use a longtext field for more than {cap})") + } + )), + _ => {} + } + } + if is_num { + let shape = f.kind.decimal_shape(); + let mut bound = |key: &str, b: Option| match (b, shape) { + (None, _) => {} + (Some(Bound::Int(_)), None) => {} + (Some(Bound::Dec(_)), None) => out.push(format!( + "`{key}` must be a whole number for kind {:?}, not a decimal", + f.kind.name() + )), + (Some(Bound::Int(_)), Some(_)) => out.push(format!( + "`{key}` of a decimal field must be a decimal at the scale of the field" + )), + (Some(Bound::Dec(d)), Some((precision, scale, _))) => { + if d.scale() != scale || d.digits() > u32::from(precision) { + out.push(format!( + "`{key}` {d} does not fit the field: a decimal of {precision} digits, {scale} after the point" + )); + } + } + }; + bound("min", l.min); + bound("max", l.max); + if let (Some(lo), Some(hi)) = (l.min, l.max) { + let above = match (lo, hi) { + (Bound::Int(a), Bound::Int(b)) => a > b, + (Bound::Dec(a), Bound::Dec(b)) => a.compare(b) == Ordering::Greater, + _ => false, + }; + if above { + out.push(format!("`min` {lo} is above `max` {hi}, so no value fits")); + } + } + } + out +} diff --git a/crates/kernel/obop-registry/src/limits_tests.rs b/crates/kernel/obop-registry/src/limits_tests.rs new file mode 100644 index 00000000..05afc03f --- /dev/null +++ b/crates/kernel/obop-registry/src/limits_tests.rs @@ -0,0 +1,639 @@ +//! Tests of the field limits (D90) at the level of the registry: counting, the default, exact +//! bounds, the sentences, and the install gate. + +use crate::limits::*; +use crate::{FieldDef, FieldKind, ModelDef, Module, Reset, check_module}; +use obop_types::DataClass; +use obop_values::Decimal; + +fn f(name: &'static str) -> FieldDef { + FieldDef::new(name, "d", DataClass::None, false, "t.user") +} +fn dec(s: &str, scale: u8) -> Bound { + Bound::Dec(Decimal::parse_for(s, 12, scale, None).unwrap()) +} +fn with(field: FieldDef, l: Limits) -> FieldDef { + field.with_limits(l) +} + +#[test] +fn the_defaults_are_below_the_caps_and_named_in_one_place() { + assert_eq!((DEFAULT_TEXT_MAX, DEFAULT_LONGTEXT_MAX), (2_048, 100_000)); + assert_eq!((TEXT_MAX_CAP, LONGTEXT_MAX_CAP), (10_000, 1_000_000)); + // The order of the four is checked by the compiler, so a change of one number that breaks it + // does not build: each default is below its cap, and a text may not be allowed more than a + // longtext has by default. + const { + assert!(DEFAULT_TEXT_MAX < TEXT_MAX_CAP && DEFAULT_LONGTEXT_MAX < LONGTEXT_MAX_CAP); + assert!(TEXT_MAX_CAP <= DEFAULT_LONGTEXT_MAX); + } +} + +#[test] +fn the_effective_maximum_is_the_declared_one_or_the_default_of_the_kind() { + assert_eq!(f("a").effective_max_length(), Some(DEFAULT_TEXT_MAX)); + assert_eq!( + f("a").kind(FieldKind::LongText).effective_max_length(), + Some(DEFAULT_LONGTEXT_MAX) + ); + let own = with( + f("a"), + Limits { + max_length: Some(30), + ..Default::default() + }, + ); + assert_eq!(own.effective_max_length(), Some(30)); + // A declared value may also be above the default, up to the cap. + let big = with( + f("a"), + Limits { + max_length: Some(TEXT_MAX_CAP), + ..Default::default() + }, + ); + assert_eq!(big.effective_max_length(), Some(TEXT_MAX_CAP)); + // No length for the other kinds, and none for a field the system writes. + for k in [ + FieldKind::Number, + FieldKind::Money, + FieldKind::Date, + FieldKind::Bool, + ] { + assert_eq!(f("a").kind(k.clone()).effective_max_length(), None, "{k:?}"); + } + assert_eq!(f("a").computed("1").effective_max_length(), None); + assert_eq!( + f("a") + .sequence("{seq}", false, Reset::Never) + .effective_max_length(), + None + ); + assert_eq!(f("a").effective_min_length(), None); +} + +/// The count the plain way, to compare the shortcut by bytes against. +fn naive(s: &str, min: Option, max: Option) -> Result<(), Breach> { + let have = s.chars().count(); + if let Some(m) = max + && have > m as usize + { + return Err(Breach::TooLong { max: m, have }); + } + if let Some(m) = min + && have < m as usize + { + return Err(Breach::TooShort { min: m, have }); + } + Ok(()) +} + +#[test] +fn counting_by_bytes_first_never_changes_the_answer() { + // One letter of each width in UTF-8 (1, 2, 3 and 4 bytes) and a letter with a combining mark. + let alphabet = ["a", "å", "€", "\u{1F600}", "e\u{301}"]; + let mut strings = vec![String::new()]; + let mut layer = vec![String::new()]; + for _ in 0..4 { + layer = layer + .iter() + .flat_map(|s| alphabet.iter().map(move |c| format!("{s}{c}"))) + .collect(); + strings.extend(layer.iter().cloned()); + } + let mut checked = 0usize; + for s in &strings { + for max in [None, Some(1), Some(2), Some(3), Some(5), Some(8)] { + for min in [None, Some(1), Some(2), Some(4)] { + if let (Some(lo), Some(hi)) = (min, max) + && lo > hi + { + continue; + } + let field = with( + f("a"), + Limits { + min_length: min, + max_length: max, + ..Default::default() + }, + ); + // With no maximum declared the default (2,048) applies; none of these strings is + // near it, so the plain count with no maximum gives the same answer. + assert_eq!( + field.check_text(s), + naive(s, min, max), + "{s:?} min {min:?} max {max:?}" + ); + checked += 1; + } + } + } + assert!(checked > 5_000, "{checked}"); +} + +#[test] +fn characters_are_code_points() { + assert_eq!(char_count("åäö"), 3); + assert_eq!(char_count("\u{1F600}"), 1); + // A flag is two regional indicators and a family five code points: counted as they are. + assert_eq!(char_count("\u{1F1EB}\u{1F1EE}"), 2); + assert_eq!(char_count("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"), 5); + let three = with( + f("a"), + Limits { + max_length: Some(3), + ..Default::default() + }, + ); + assert_eq!(three.check_text("åäö"), Ok(())); + assert_eq!(three.check_text("\u{1F600}\u{1F600}\u{1F600}"), Ok(())); + assert_eq!( + three.check_text("\u{1F600}\u{1F600}\u{1F600}\u{1F600}"), + Err(Breach::TooLong { max: 3, have: 4 }) + ); +} + +#[test] +fn the_default_applies_to_a_text_field_and_a_longtext_has_its_own() { + let s = "x".repeat(DEFAULT_TEXT_MAX as usize); + assert_eq!(f("a").check_text(&s), Ok(())); + assert_eq!( + f("a").check_text(&format!("{s}x")), + Err(Breach::TooLong { + max: DEFAULT_TEXT_MAX, + have: DEFAULT_TEXT_MAX as usize + 1 + }) + ); + assert_eq!( + f("a") + .kind(FieldKind::LongText) + .check_text(&format!("{s}x")), + Ok(()) + ); +} + +#[test] +fn whole_number_and_decimal_bounds_are_inclusive_and_exact() { + let n = with( + f("n").kind(FieldKind::Number), + Limits { + min: Some(Bound::Int(-3)), + max: Some(Bound::Int(3)), + ..Default::default() + }, + ); + assert_eq!(n.check_int(-3), Ok(())); + assert_eq!(n.check_int(3), Ok(())); + assert_eq!(n.check_int(-4), Err(Breach::Below(Bound::Int(-3)))); + assert_eq!(n.check_int(4), Err(Breach::Above(Bound::Int(3)))); + assert_eq!(f("n").kind(FieldKind::Number).check_int(i64::MIN), Ok(())); + + // Seven digits after the point: 0.1000001 is above 0.1000000 and 0.0999999 is below it, + // which a double cannot tell apart from 0.1. + let d = with( + f("d").decimal(12, 7), + Limits { + min: Some(dec("0.1000000", 7)), + max: Some(dec("0.9000000", 7)), + ..Default::default() + }, + ); + let v = |s: &str| Decimal::parse_for(s, 12, 7, None).unwrap(); + assert_eq!(d.check_decimal(v("0.1000000")), Ok(())); + assert_eq!(d.check_decimal(v("0.1000001")), Ok(())); + assert_eq!(d.check_decimal(v("0.9000000")), Ok(())); + assert!(d.check_decimal(v("0.0999999")).is_err()); + assert!(d.check_decimal(v("0.9000001")).is_err()); + assert_eq!( + d.check_decimal(v("-0.0000001")), + Err(Breach::Below(dec("0.1000000", 7))) + ); + // Larger than a double holds exactly: 2^53 + 1 digits apart. + let big = with( + f("d").decimal(20, 0), + Limits { + max: Some(Bound::Dec( + Decimal::parse_for("9007199254740993", 20, 0, None).unwrap(), + )), + ..Default::default() + }, + ); + let at = |s: &str| Decimal::parse_for(s, 20, 0, None).unwrap(); + assert_eq!(big.check_decimal(at("9007199254740993")), Ok(())); + assert!(big.check_decimal(at("9007199254740994")).is_err()); +} + +#[test] +fn a_breach_says_what_was_wanted_and_what_was_written() { + let msg = |b: Breach, v: &str| b.message("m", "f", v); + assert_eq!( + msg( + Breach::TooLong { + max: 2000, + have: 2001 + }, + "" + ), + "m.f must be at most 2000 characters; this has 2001" + ); + assert_eq!( + msg(Breach::TooLong { max: 1, have: 2 }, ""), + "m.f must be at most 1 character; this has 2" + ); + assert_eq!( + msg(Breach::TooShort { min: 3, have: 0 }, ""), + "m.f must be at least 3 characters; this has 0" + ); + assert_eq!( + msg(Breach::Below(Bound::Int(0)), "-5"), + "m.f must be at least 0; this is -5" + ); + assert_eq!( + msg(Breach::Above(dec("0.90", 2)), "1.00"), + "m.f must be at most 0.90; this is 1.00" + ); +} + +/// The refusal for a caller who may not be told the size of what was written (an agent holding a +/// masked token, a value an action copied from a field the caller cannot read; the review of +/// PR 33, F7) says what the field takes and nothing about the value. +#[test] +fn a_refusal_without_the_value_says_only_what_the_field_takes() { + let all = [ + ( + Breach::TooLong { + max: 10, + have: 4_000, + }, + "m.f must be at most 10 characters", + ), + ( + Breach::TooLong { max: 1, have: 40 }, + "m.f must be at most 1 character", + ), + ( + Breach::TooShort { min: 3, have: 0 }, + "m.f must be at least 3 characters", + ), + (Breach::Below(Bound::Int(0)), "m.f must be at least 0"), + (Breach::Above(Bound::Int(100)), "m.f must be at most 100"), + (Breach::Above(dec("0.90", 2)), "m.f must be at most 0.90"), + ]; + for (b, want) in all { + assert_eq!(b.message_without_value("m", "f"), want); + assert_eq!(b.requirement(), want.strip_prefix("m.f must be ").unwrap()); + // The sentence with the value is the same one plus the value. + assert!( + b.message("m", "f", "73250").starts_with(want), + "{}", + b.message("m", "f", "73250") + ); + assert!(!b.message_without_value("m", "f").contains("this ")); + } +} + +#[test] +fn the_note_for_descriptions_only_speaks_of_declared_limits() { + assert_eq!(f("a").limits_note(), ""); + let own = |l: Limits| with(f("a"), l).limits_note(); + assert_eq!( + own(Limits { + max_length: Some(100), + ..Default::default() + }), + " (at most 100 characters)" + ); + assert_eq!( + own(Limits { + min_length: Some(1), + ..Default::default() + }), + " (at least 1 character)" + ); + assert_eq!( + own(Limits { + min_length: Some(1), + max_length: Some(80), + ..Default::default() + }), + " (from 1 character to 80 characters)" + ); + assert_eq!( + limits_note(None, None, Some(Bound::Int(0)), Some(Bound::Int(100))), + " (from 0 to 100)" + ); + assert_eq!( + limits_note(None, None, None, Some(Bound::Int(100))), + " (at most 100)" + ); +} + +// ---- the install gate --------------------------------------------------------------------- + +fn gate(field: FieldDef) -> Vec { + problems(&field) +} + +#[test] +fn the_gate_accepts_what_makes_sense_and_a_field_with_no_limits_is_free() { + assert!(gate(f("a")).is_empty()); + let ok = [ + with( + f("a"), + Limits { + min_length: Some(1), + max_length: Some(1), + ..Default::default() + }, + ), + with( + f("a"), + Limits { + max_length: Some(TEXT_MAX_CAP), + ..Default::default() + }, + ), + with( + f("a").kind(FieldKind::LongText), + Limits { + max_length: Some(LONGTEXT_MAX_CAP), + ..Default::default() + }, + ), + with( + f("a").kind(FieldKind::Number), + Limits { + min: Some(Bound::Int(5)), + max: Some(Bound::Int(5)), + ..Default::default() + }, + ), + with( + f("a").decimal(10, 2), + Limits { + min: Some(dec("0.00", 2)), + max: Some(dec("99999999.99", 2)), + ..Default::default() + }, + ), + ]; + for field in ok { + assert_eq!( + gate(field.clone()), + Vec::::new(), + "{:?}", + field.limits + ); + } +} + +fn has(found: Vec, needle: &str) { + assert!( + found.iter().any(|p| p.contains(needle)), + "wanted {needle:?} in {found:?}" + ); +} + +#[test] +fn the_gate_refuses_a_length_that_cannot_hold() { + let text = |l: Limits| gate(with(f("a"), l)); + let long = |l: Limits| gate(with(f("a").kind(FieldKind::LongText), l)); + has( + text(Limits { + max_length: Some(0), + ..Default::default() + }), + "`max_length` must be at least 1", + ); + has( + text(Limits { + min_length: Some(0), + ..Default::default() + }), + "`min_length` must be at least 1", + ); + has( + text(Limits { + max_length: Some(TEXT_MAX_CAP + 1), + ..Default::default() + }), + "above the largest a text field may declare, 10000", + ); + has( + long(Limits { + max_length: Some(LONGTEXT_MAX_CAP + 1), + ..Default::default() + }), + "above the largest a longtext field may declare, 1000000", + ); + has( + text(Limits { + min_length: Some(10), + max_length: Some(9), + ..Default::default() + }), + "`min_length` 10 is above `max_length` 9", + ); + has( + text(Limits { + min_length: Some(DEFAULT_TEXT_MAX + 1), + ..Default::default() + }), + "above the default maximum", + ); +} + +/// The advice for a `min_length` with no `max_length` must not send the author to a number the +/// gate refuses next (the review of PR 33, F20): it names the cap, and above the cap it says so +/// directly and points to a longtext field. +#[test] +fn the_advice_for_a_min_length_without_a_max_length_names_the_cap() { + let only = |field: FieldDef, min: u32| { + gate(with( + field, + Limits { + min_length: Some(min), + ..Default::default() + }, + )) + }; + // Between the default and the cap: declare a max_length, at most the cap; text suggests + // longtext for more. + let found = only(f("a"), DEFAULT_TEXT_MAX + 1); + has( + found.clone(), + "declare a `max_length` of at least 2049, at most 10000", + ); + has(found, "use a longtext field for more than 10000"); + let found = only(f("a").kind(FieldKind::LongText), DEFAULT_LONGTEXT_MAX + 1); + has( + found.clone(), + "declare a `max_length` of at least 100001, at most 1000000", + ); + assert!( + !found.iter().any(|p| p.contains("use a longtext field")), + "a longtext field has nowhere larger to go: {found:?}" + ); + // Above the cap no max_length can help: the cap is reported directly, with no advice that + // the gate would refuse. + let found = only(f("a"), TEXT_MAX_CAP + 1); + has( + found.clone(), + "`min_length` 10001 is above 10000, the most a text field can hold; use a longtext field for more (up to 1000000)", + ); + assert!( + !found.iter().any(|p| p.contains("declare a `max_length`")), + "no advice to declare a max_length that is itself above the cap: {found:?}" + ); + let found = only(f("a").kind(FieldKind::LongText), LONGTEXT_MAX_CAP + 1); + has( + found.clone(), + "`min_length` 1000001 is above 1000000, the most a longtext field can hold", + ); + assert!( + !found.iter().any(|p| p.contains("use a longtext field")), + "{found:?}" + ); + // At the cap itself it fits, with the max_length advice. + has( + only(f("a"), TEXT_MAX_CAP), + "declare a `max_length` of at least 10000, at most 10000", + ); +} + +#[test] +fn the_gate_refuses_keys_on_the_wrong_kind() { + has( + gate(with( + f("a").kind(FieldKind::Number), + Limits { + max_length: Some(5), + ..Default::default() + }, + )), + "only valid for kind \"text\" or \"longtext\"", + ); + has( + gate(with( + f("a").kind(FieldKind::Date), + Limits { + min: Some(Bound::Int(1)), + ..Default::default() + }, + )), + "only valid for kind \"number\", \"money\" or \"decimal\"", + ); + has( + gate(with( + f("a"), + Limits { + max: Some(Bound::Int(1)), + ..Default::default() + }, + )), + "only valid for kind \"number\"", + ); + // A field the system writes takes none. + has( + gate(with( + f("a").computed("1"), + Limits { + max_length: Some(3), + ..Default::default() + }, + )), + "computed or numbered", + ); +} + +#[test] +fn the_gate_refuses_bounds_that_do_not_fit_the_kind_or_each_other() { + has( + gate(with( + f("a").kind(FieldKind::Number), + Limits { + min: Some(dec("1.0", 1)), + ..Default::default() + }, + )), + "must be a whole number", + ); + has( + gate(with( + f("a").decimal(10, 2), + Limits { + max: Some(Bound::Int(5)), + ..Default::default() + }, + )), + "must be a decimal at the scale of the field", + ); + has( + gate(with( + f("a").decimal(10, 2), + Limits { + max: Some(dec("1.000", 3)), + ..Default::default() + }, + )), + "does not fit the field", + ); + has( + gate(with( + f("a").kind(FieldKind::Number), + Limits { + min: Some(Bound::Int(2)), + max: Some(Bound::Int(1)), + ..Default::default() + }, + )), + "`min` 2 is above `max` 1", + ); + has( + gate(with( + f("a").decimal(10, 2), + Limits { + min: Some(dec("0.11", 2)), + max: Some(dec("0.10", 2)), + ..Default::default() + }, + )), + "`min` 0.11 is above `max` 0.10", + ); +} + +#[test] +fn check_module_runs_the_gate_so_install_and_start_both_refuse() { + let bad = with( + f("title"), + Limits { + max_length: Some(0), + ..Default::default() + }, + ); + let module = Module { + id: "t".into(), + name: "T".into(), + models: vec![ModelDef { + name: "t_doc", + description: "d", + read_group: "t.user", + write_group: "t.user", + access: None, + fields: vec![bad], + commands: vec![], + ext: Default::default(), + }], + network_destinations: vec![], + description: String::new(), + translations: Default::default(), + meta: Default::default(), + }; + let v = check_module(&module); + assert!( + v.iter() + .any(|v| v.path.contains("t_doc.title") && v.problem.contains("max_length")), + "{v:?}" + ); +} diff --git a/crates/modules/connectors/src/tests.rs b/crates/modules/connectors/src/tests.rs index feef6e31..f8cf65a9 100644 --- a/crates/modules/connectors/src/tests.rs +++ b/crates/modules/connectors/src/tests.rs @@ -1136,6 +1136,111 @@ fn a_signed_webhook_creates_records_through_the_governed_commands() { )); } +#[test] +fn an_inbound_item_over_a_limit_is_rejected_by_itself_and_the_rest_are_kept() { + // D90 (review of PR 33, F9): a record a sender pushes is written through `lead.create`, so the + // limits of the field (here the default of a text field) apply to it like to any write. The + // item that breaks one is rejected; the others in the same delivery are stored. + let e = env(); + enable(&e, "webhook_in", true); + let c = ok( + &e, + &sam(), + "connection.create", + json!({ + "name": "Leads in bulk", "provider": "webhook_in", + "config": json!({"target_model": "lead", "mapping": json!({"title": "title"}).to_string(), "records_path": "items", "external_id_key": "id"}).to_string(), + "credentials": json!({"signing_secret": SECRET}).to_string(), + "field_allowlist": json!({"lead": ["title"]}).to_string()}), + ); + let id = c["id"].as_str().unwrap().to_string(); + ok( + &e, + &sam(), + "consent.create", + json!({"connection": id, "purpose": "Create leads from the website form", "legal_basis": "legitimate_interest", "note": "Balancing test: the visitor asked us to get in touch."}), + ); + ok(&e, &sam(), "connection.connect", json!({"id": id})); + let send = |body: &J, evt: &str| { + let b = body.to_string(); + let ts = obop_connectors::util::now(); + let sig = webhook::sign_standard(SECRET, evt, ts, b.as_bytes()); + let evt = evt.to_string(); + let hdr = move |k: &str| match k { + "webhook-id" => Some(evt.clone()), + "webhook-timestamp" => Some(ts.to_string()), + "webhook-signature" => Some(sig.clone()), + _ => None, + }; + e.rt.receive_webhook(&id, &hdr, b.as_bytes()) + }; + let max = obop_registry::DEFAULT_TEXT_MAX as usize; + let at_the_limit = "ä".repeat(max); + let over = "ä".repeat(max + 1); + + // One delivery: fits, over by one, fits at the limit exactly. + let o = send( + &json!({"items": [ + {"id": 1, "title": "fits"}, + {"id": 2, "title": over}, + {"id": 3, "title": at_the_limit}, + ]}), + "evt_1", + ) + .expect("a delivery with at least one accepted item is answered, with counts"); + assert_eq!((o.created, o.updated), (2, 0)); + assert_eq!(o.rejected.len(), 1, "{:?}", o.rejected); + assert_eq!(o.rejected[0].0, 1, "the second item is the one rejected"); + assert!( + o.rejected[0] + .1 + .contains(&format!("lead.title must be at most {max} characters")), + "{}", + o.rejected[0].1 + ); + assert_eq!(e.s.backend().list("lead").unwrap().len(), 2); + + // The run records it: a partial run, and the first message. + let runs = ok(&e, &sam(), "sync_run.list", json!({})); + assert!( + runs.as_array() + .unwrap() + .iter() + .any(|r| r["kind"] == "webhook" && r["status"] == "partial"), + "{runs}" + ); + + // Every item over a limit: the whole delivery is refused (422 on the wire), nothing stored. + let m = send(&json!({"items": [{"id": 4, "title": over}]}), "evt_2").unwrap_err(); + assert!( + matches!(m, obop_connectors::runtime::HookError::Rejected(ref s) + if s.contains(&format!("lead.title must be at most {max} characters"))), + "{m:?}" + ); + assert_eq!(e.s.backend().list("lead").unwrap().len(), 2); + let runs = ok(&e, &sam(), "sync_run.list", json!({})); + assert!( + runs.as_array() + .unwrap() + .iter() + .any(|r| r["kind"] == "webhook" && r["status"] == "failed"), + "{runs}" + ); + + // The event id was taken, so the sender's retry of it is a replay; a new delivery is fine. + assert_eq!( + send(&json!({"items": [{"id": 4, "title": over}]}), "evt_2").unwrap_err(), + obop_connectors::runtime::HookError::Unauthorised + ); + let o = send( + &json!({"items": [{"id": 4, "title": "short now"}]}), + "evt_3", + ) + .unwrap(); + assert_eq!((o.created, o.updated), (1, 0)); + assert_eq!(e.s.backend().list("lead").unwrap().len(), 3); +} + #[test] fn a_connector_can_never_do_more_than_its_owner_and_approval_actions_stay_proposals() { let e = env(); diff --git a/crates/obop/src/build_commit.rs b/crates/obop/src/build_commit.rs index a0bfeb2a..afc82d36 100644 --- a/crates/obop/src/build_commit.rs +++ b/crates/obop/src/build_commit.rs @@ -27,7 +27,7 @@ pub fn short(raw: &str) -> Option { digits.then(|| raw[..SHORT].to_ascii_lowercase()) } -/// The line `obop --version` prints: `obop 0.2.0 (abcdef012345)`. The prefix `obop 0.2.0` is the +/// The line `obop --version` prints: `obop 0.2.1 (abcdef012345)`. The prefix `obop 0.2.1` is the /// one earlier builds printed alone. pub fn version_line(name: &str, version: &str, commit: &str) -> String { format!("{name} {version} ({commit})") diff --git a/crates/obop/src/health_tests.rs b/crates/obop/src/health_tests.rs index 64744c4a..38a7ab7b 100644 --- a/crates/obop/src/health_tests.rs +++ b/crates/obop/src/health_tests.rs @@ -148,7 +148,7 @@ async fn health_endpoints_are_open_and_reveal_nothing_but_status_and_a_fixed_rea for path in ["/healthz", "/readyz"] { let r = send(&f.router, get(path)).await; for secret in [ - "ada", "sqlite", "memory", "postgres", "obop_", "0.2.0", "token", + "ada", "sqlite", "memory", "postgres", "obop_", "0.2.1", "token", ] { assert!( !r.body.contains(secret), diff --git a/crates/obop/src/limits_tests.rs b/crates/obop/src/limits_tests.rs new file mode 100644 index 00000000..68654b31 --- /dev/null +++ b/crates/obop/src/limits_tests.rs @@ -0,0 +1,739 @@ +//! Field limits (D90) over HTTP and MCP against a real `App`: the 400 with its code, hint, field +//! and numbers on create, update and `bulk_create`, the rule for a record stored above a lowered +//! limit, the default on the built-in models, and what `GET /api/meta`, the OpenAPI document and +//! the MCP tool schemas say. The package under test is installed through `module.install` like +//! any other. + +use crate::app::App; +use axum::{body::Body, http::Request}; +use http_body_util::BodyExt; +use obop_auth::{Auth, AuthConfig}; +use serde_json::{Value as J, json}; +use std::sync::Arc; +use tower::ServiceExt; + +const PW: &str = "correct horse battery"; + +/// A package with a field of every kind that takes a limit, and one of each without. +const LIM: &str = r#" +[module] +id = "lim" +version = "0.1.0" +name = "Lim" +description = "Limits" +license = "MIT" +requires_core = "^0.2.1" + +[[model]] +name = "lim_note" +description = "A note" +read_group = "base.user" +write_group = "base.user" + + [[model.field]] + name = "title" + description = "Title" + class = "none" + max_length = 2000 + min_length = 1 + + [[model.field]] + name = "body" + description = "Body" + class = "none" + kind = "longtext" + + [[model.field]] + name = "plain" + description = "Plain text with no limit declared" + class = "none" + + [[model.field]] + name = "qty" + description = "Quantity" + class = "none" + kind = "number" + min = 0 + max = 100 + + [[model.field]] + name = "price" + description = "Price in minor units" + class = "none" + kind = "money" + min = 0 + max = 100000 + + [[model.field]] + name = "rate" + description = "Rate" + class = "none" + kind = "decimal" + precision = 12 + scale = 7 + min = "0.1000000" + max = "0.9000000" +"#; + +struct Rig { + r: axum::Router, + app: App, + token: String, +} + +async fn send(r: &axum::Router, method: &str, token: &str, path: &str, body: J) -> (u16, J) { + let req = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap(); + let resp = r.clone().oneshot(req).await.unwrap(); + let status = resp.status().as_u16(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(J::Null)) +} + +impl Rig { + /// A server with a user who may do everything and no package installed. + async fn bare() -> Rig { + let app = App::new(); + app.seed_demo(); + let auth = Arc::new(Auth::with_test_config( + app.store.backend(), + AuthConfig { + default_require_admins: false, + ..AuthConfig::insecure_dev() + }, + )); + auth.create_user("root", PW, &app.all_groups()).unwrap(); + let r = obop_http::router( + obop_http::HttpState::new(app.dispatcher.clone(), auth.clone()), + None, + ); + let (s, b) = send( + &r, + "POST", + "", + "/api/auth/login", + json!({"username": "root", "password": PW}), + ) + .await; + assert_eq!(s, 200, "{b}"); + let token = b["token"].as_str().unwrap().to_string(); + Rig { r, app, token } + } + /// As `bare`, with the package `lim` installed. + async fn new() -> Rig { + let rig = Rig::bare().await; + let (s, b) = rig + .post("/api/module/install", json!({"manifest": LIM})) + .await; + assert_eq!(s, 200, "{b}"); + rig + } + async fn post(&self, path: &str, body: J) -> (u16, J) { + send(&self.r, "POST", &self.token, path, body).await + } + async fn get(&self, path: &str) -> J { + let (s, b) = send(&self.r, "GET", &self.token, path, J::Null).await; + assert_eq!(s, 200, "{path}: {b}"); + b + } + async fn create(&self, model: &str, body: J) -> (u16, J) { + self.post(&format!("/api/{model}/create"), body).await + } + async fn count(&self, model: &str) -> u64 { + let (s, b) = self + .post(&format!("/api/{model}/search"), json!({"limit": 1})) + .await; + assert_eq!(s, 200, "{b}"); + b["value"]["total"].as_u64().unwrap() + } +} + +fn error(b: &J) -> String { + b["error"].as_str().unwrap_or_default().to_string() +} + +#[tokio::test] +async fn rest_refuses_a_value_outside_a_limit_with_a_400_that_names_the_field_and_the_numbers() { + let rig = Rig::new().await; + let long = "x".repeat(2001); + let (s, b) = rig.create("lim_note", json!({"title": long})).await; + assert_eq!(s, 400, "{b}"); + assert_eq!( + error(&b), + "lim_note.title must be at most 2000 characters; this has 2001" + ); + assert_eq!(b["code"], "limit_violated"); + assert!(b["hint"].as_str().unwrap().contains("GET /api/meta"), "{b}"); + // 2,000 characters are accepted (the limit is inclusive), counted as characters. + let (s, b) = rig + .create("lim_note", json!({"title": "å".repeat(2000)})) + .await; + assert_eq!(s, 200, "{b}"); + let (s, b) = rig + .create("lim_note", json!({"title": "\u{1F600}".repeat(2001)})) + .await; + assert_eq!(s, 400, "{b}"); + assert!(error(&b).ends_with("at most 2000 characters; this has 2001")); + + for (field, value, want) in [ + ( + "title", + json!(""), + "must be at least 1 character; this has 0", + ), + ("qty", json!(-1), "must be at least 0; this is -1"), + ("qty", json!(101), "must be at most 100; this is 101"), + ( + "price", + json!(100_001), + "must be at most 100000; this is 100001", + ), + ( + "rate", + json!("0.0999999"), + "must be at least 0.1000000; this is 0.0999999", + ), + ( + "rate", + json!("0.9000001"), + "must be at most 0.9000000; this is 0.9000001", + ), + ] { + let (s, b) = rig + .create("lim_note", json!({"title": "t", field: value})) + .await; + assert_eq!(s, 400, "{field}: {b}"); + assert!( + error(&b).starts_with(&format!("lim_note.{field} ")) && error(&b).contains(want), + "{field}: {b}" + ); + assert_eq!(b["code"], "limit_violated", "{field}"); + } + // Nothing was stored by any of the refused calls. + assert_eq!(rig.count("lim_note").await, 1); + + // The inclusive ends are fine. + for body in [ + json!({"title": "t", "qty": 0, "price": 0, "rate": "0.1000000"}), + json!({"title": "t", "qty": 100, "price": 100_000, "rate": "0.9000000"}), + ] { + let (s, b) = rig.create("lim_note", body).await; + assert_eq!(s, 200, "{b}"); + } +} + +#[tokio::test] +async fn rest_update_holds_a_changed_value_to_the_limit_and_spares_an_unchanged_one() { + let rig = Rig::new().await; + let (_, b) = rig + .create("lim_note", json!({"title": "ok", "qty": 5})) + .await; + let id = b["value"]["id"].as_str().unwrap().to_string(); + let (s, b) = rig + .post("/api/lim_note/update", json!({"id": id, "qty": 101})) + .await; + assert_eq!( + (s, b["code"].as_str()), + (400, Some("limit_violated")), + "{b}" + ); + assert_eq!(error(&b), "lim_note.qty must be at most 100; this is 101"); + + // Behind the commands' back: a record from before the limit was lowered. + let patch = [ + ("title".to_string(), json!("t".repeat(3000))), + ("qty".to_string(), json!(5000)), + ] + .into_iter() + .collect(); + assert!( + rig.app + .store + .backend() + .update("lim_note", &id, &patch) + .unwrap() + ); + // Another field can be changed, with or without the stored values sent back with it. + let (s, b) = rig + .post("/api/lim_note/update", json!({"id": id, "plain": "edited"})) + .await; + assert_eq!(s, 200, "{b}"); + let (s, b) = rig + .post( + "/api/lim_note/update", + json!({"id": id, "title": "t".repeat(3000), "qty": 5000, "plain": "again"}), + ) + .await; + assert_eq!(s, 200, "{b}"); + // A changed value is held to the limit, however far above it the stored one was. + let (s, b) = rig + .post( + "/api/lim_note/update", + json!({"id": id, "title": "u".repeat(3000)}), + ) + .await; + assert_eq!(s, 400, "{b}"); + assert_eq!( + error(&b), + "lim_note.title must be at most 2000 characters; this has 3000" + ); +} + +#[tokio::test] +async fn rest_bulk_create_names_the_row_and_csv_goes_through_the_same_check() { + let rig = Rig::new().await; + let (s, b) = rig + .post( + "/api/lim_note/bulk_create", + json!({"rows": [ + {"title": "fine"}, + {"title": "x".repeat(2001)}, + {"title": "fine", "qty": 101}, + ]}), + ) + .await; + assert_eq!(s, 400, "{b}"); + let e = error(&b); + assert!( + e.contains("2 of 3 row(s) are invalid") + && e.contains("row 2") + && e.contains("lim_note.title must be at most 2000 characters; this has 2001") + && e.contains("row 3") + && e.contains("lim_note.qty must be at most 100; this is 101"), + "{e}" + ); + assert_eq!(rig.count("lim_note").await, 0); + + // Partial mode: the errors come as a list with the row number and no code in the text. + let (s, b) = rig + .post( + "/api/lim_note/bulk_create", + json!({"mode": "partial", "rows": [ + {"title": "fine"}, + {"title": "x".repeat(2001)}, + ]}), + ) + .await; + assert_eq!(s, 200, "{b}"); + assert_eq!(b["value"]["created"], 1, "{b}"); + assert_eq!(b["value"]["errors"][0]["row"], 2); + assert_eq!( + b["value"]["errors"][0]["error"], + "lim_note.title must be at most 2000 characters; this has 2001" + ); + + let csv = format!("title,qty\nok,1\n{},1\nok,500\n", "y".repeat(2001)); + let (s, b) = rig + .post("/api/lim_note/bulk_create", json!({"csv": csv})) + .await; + assert_eq!(s, 400, "{b}"); + assert!( + error(&b).contains("row 2") && error(&b).contains("row 3"), + "{b}" + ); + assert_eq!(rig.count("lim_note").await, 1); +} + +#[tokio::test] +async fn the_built_in_models_have_the_default_length() { + let rig = Rig::new().await; + let (s, b) = rig.create("party", json!({"name": "a".repeat(2048)})).await; + assert_eq!(s, 200, "{b}"); + let (s, b) = rig.create("party", json!({"name": "a".repeat(2049)})).await; + assert_eq!(s, 400, "{b}"); + assert_eq!( + error(&b), + "party.name must be at most 2048 characters; this has 2049" + ); + // A longtext field of a built-in model has the larger default. + let (s, b) = rig + .create("party", json!({"name": "n", "notes": "z".repeat(100_000)})) + .await; + assert_eq!(s, 200, "{b}"); + let (s, b) = rig + .create("party", json!({"name": "n", "notes": "z".repeat(100_001)})) + .await; + assert_eq!(s, 400, "{b}"); + assert_eq!( + error(&b), + "party.notes must be at most 100000 characters; this has 100001" + ); +} + +#[tokio::test] +async fn meta_says_the_limit_of_every_field_that_has_one() { + let rig = Rig::new().await; + let meta = rig.get("/api/meta").await; + let fields = |model: &str| -> Vec { + for m in meta["modules"].as_array().unwrap() { + for d in m["models"].as_array().unwrap() { + if d["name"] == model { + return d["fields"].as_array().unwrap().clone(); + } + } + } + panic!("no model {model}"); + }; + let field = |model: &str, name: &str| -> J { + fields(model) + .into_iter() + .find(|f| f["name"] == name) + .unwrap_or_else(|| panic!("no field {model}.{name}")) + }; + let t = field("lim_note", "title"); + assert_eq!( + (t["max_length"].clone(), t["min_length"].clone()), + (json!(2000), json!(1)) + ); + // The default is stated, not left for the client to guess; no minimum is invented. + let p = field("lim_note", "plain"); + assert_eq!(p["max_length"], 2048); + assert!(p.get("min_length").is_none(), "{p}"); + assert_eq!(field("lim_note", "body")["max_length"], 100_000); + let q = field("lim_note", "qty"); + assert_eq!((q["min"].clone(), q["max"].clone()), (json!(0), json!(100))); + assert!(q.get("max_length").is_none(), "{q}"); + let m = field("lim_note", "price"); + assert_eq!( + (m["min"].clone(), m["max"].clone()), + (json!(0), json!(100_000)) + ); + // A decimal bound is an exact string at the scale of the field, never a number. + let d = field("lim_note", "rate"); + assert_eq!( + (d["min"].clone(), d["max"].clone()), + (json!("0.1000000"), json!("0.9000000")) + ); + // Every writable text field of every model says its maximum; no other kind does. + let mut texts = 0; + for m in meta["modules"].as_array().unwrap() { + for d in m["models"].as_array().unwrap() { + for f in d["fields"].as_array().unwrap() { + let kind = f["kind"].as_str().unwrap(); + let system = f["computed"] == true || f["sequence"] == true; + if matches!(kind, "text" | "longtext") && !system { + texts += 1; + let max = f["max_length"].as_u64(); + assert!( + max.is_some_and(|n| (1..=1_000_000).contains(&n)), + "{}.{} has no maximum: {f}", + d["name"], + f["name"] + ); + } else { + assert!( + f.get("max_length").is_none() && f.get("min_length").is_none(), + "{}.{} should have no length: {f}", + d["name"], + f["name"] + ); + } + if !matches!(kind, "number" | "money" | "decimal") { + assert!(f.get("min").is_none() && f.get("max").is_none(), "{f}"); + } + } + } + } + assert!(texts > 50, "{texts}"); +} + +#[tokio::test] +async fn the_openapi_document_has_the_keywords_on_inputs_and_not_on_records() { + let rig = Rig::new().await; + let doc = rig.get("/api/openapi.json").await; + assert_eq!(doc["info"]["version"], "0.2.1"); + let input = |op: &str| -> J { + doc["paths"][format!("/api/lim_note/{op}")]["post"]["requestBody"]["content"] + ["application/json"]["schema"] + .clone() + }; + let c = input("create"); + let p = &c["properties"]; + assert_eq!( + ( + p["title"]["maxLength"].clone(), + p["title"]["minLength"].clone() + ), + (json!(2000), json!(1)) + ); + assert_eq!(p["body"]["maxLength"], 100_000); + assert_eq!(p["plain"]["maxLength"], 2048); + assert_eq!( + (p["qty"]["minimum"].clone(), p["qty"]["maximum"].clone()), + (json!(0), json!(100)) + ); + assert_eq!( + (p["price"]["minimum"].clone(), p["price"]["maximum"].clone()), + (json!(0), json!(100000)) + ); + // A decimal is a string: JSON Schema's `minimum` would be a number, so the bounds go in + // extension keywords, as exact strings. + assert!(p["rate"].get("minimum").is_none(), "{}", p["rate"]); + assert_eq!(p["rate"]["x-obop-minimum"], "0.1000000"); + assert_eq!(p["rate"]["x-obop-maximum"], "0.9000000"); + // A declared limit is also said in words; the default is not repeated on every field. + assert!( + p["title"]["description"] + .as_str() + .unwrap() + .ends_with("(from 1 character to 2000 characters)"), + "{}", + p["title"] + ); + assert_eq!( + p["plain"]["description"], + "Plain text with no limit declared" + ); + // The update input and the bulk rows carry them too. + assert_eq!(input("update")["properties"]["title"]["maxLength"], 2000); + let rows = &input("bulk_create")["properties"]["rows"]["items"]["properties"]; + assert_eq!(rows["title"]["maxLength"], 2000); + // The record schema is what is stored, which may be above a limit set later: no keywords. + let rec = &doc["components"]["schemas"]["lim_note"]["properties"]; + for f in ["title", "plain", "qty", "rate"] { + for k in [ + "maxLength", + "minLength", + "minimum", + "maximum", + "x-obop-minimum", + ] { + assert!(rec[f].get(k).is_none(), "{f} {k}: {}", rec[f]); + } + } + // Every writable text property of every create input has a maximum: the kinds come from + // /api/meta, because a reference or an image is a string in the schema too. + let meta = rig.get("/api/meta").await; + let mut seen = 0; + for m in meta["modules"].as_array().unwrap() { + for d in m["models"].as_array().unwrap() { + let name = d["name"].as_str().unwrap(); + let Some(item) = doc["paths"].get(format!("/api/{name}/create")) else { + continue; + }; + let props = + &item["post"]["requestBody"]["content"]["application/json"]["schema"]["properties"]; + for f in d["fields"].as_array().unwrap() { + let kind = f["kind"].as_str().unwrap(); + if !matches!(kind, "text" | "longtext") + || f["computed"] == true + || f["sequence"] == true + { + continue; + } + let fname = f["name"].as_str().unwrap(); + assert_eq!( + props[fname]["maxLength"], f["max_length"], + "{name}.{fname}: {}", + props[fname] + ); + seen += 1; + } + } + } + assert!(seen > 50, "{seen}"); +} + +// ---- MCP -------------------------------------------------------------------------------- + +const HOST: &str = "inst.test"; + +async fn mcp(r: &axum::Router, token: &str, session: Option<&str>, body: J) -> (u16, J, String) { + let mut req = Request::post("/mcp") + .header("host", HOST) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("authorization", format!("Bearer {token}")); + if let Some(s) = session { + req = req.header("mcp-session-id", s); + } + let resp = r + .clone() + .oneshot(req.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = resp.status().as_u16(); + let sid = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(J::Null), + sid, + ) +} + +async fn mcp_session(rig: &Rig) -> String { + let (s, b, sid) = mcp( + &rig.r, + &rig.token, + None, + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}}}), + ) + .await; + assert_eq!(s, 200, "{b}"); + assert_eq!(b["result"]["serverInfo"]["version"], "0.2.1", "{b}"); + let (s, _, _) = mcp( + &rig.r, + &rig.token, + Some(&sid), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + ) + .await; + assert_eq!(s, 202); + sid +} + +#[tokio::test] +async fn mcp_tool_schemas_carry_the_limits_and_a_call_over_the_limit_is_an_error_result() { + let rig = Rig::new().await; + let sid = mcp_session(&rig).await; + // The list comes in pages. + let mut tools: Vec = Vec::new(); + let mut cursor: Option = None; + loop { + let params = cursor.as_ref().map_or(json!({}), |c| json!({"cursor": c})); + let (s, list, _) = mcp( + &rig.r, + &rig.token, + Some(&sid), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":params}), + ) + .await; + assert_eq!(s, 200, "{list}"); + tools.extend(list["result"]["tools"].as_array().unwrap().clone()); + match list["result"]["nextCursor"].as_str() { + Some(c) => cursor = Some(c.to_string()), + None => break, + } + } + let tool = |name: &str| -> J { + tools + .iter() + .find(|t| t["name"] == name) + .unwrap_or_else(|| panic!("no tool {name}")) + .clone() + }; + let create = tool("lim_note__create"); + let p = &create["inputSchema"]["properties"]; + assert_eq!( + ( + p["title"]["maxLength"].clone(), + p["title"]["minLength"].clone() + ), + (json!(2000), json!(1)) + ); + assert_eq!(p["plain"]["maxLength"], 2048); + assert_eq!( + (p["qty"]["minimum"].clone(), p["qty"]["maximum"].clone()), + (json!(0), json!(100)) + ); + assert_eq!(p["rate"]["x-obop-minimum"], "0.1000000"); + assert!( + p["title"]["description"] + .as_str() + .unwrap() + .contains("(from 1 character to 2000 characters)"), + "{}", + p["title"] + ); + // An agent sees personal data as tokens such as PERSON_1, so a bound on the length of the + // real value would refuse the token: such a field gets no keyword in the tool schema. + let party = tool("party__create"); + assert!( + party["inputSchema"]["properties"]["name"] + .get("maxLength") + .is_none(), + "{}", + party["inputSchema"]["properties"]["name"] + ); + // A field of the same model that is not personal data does get it. + assert_eq!(p["body"]["maxLength"], 100_000); + + // A call that breaks a limit comes back as an error result with the sentence. + let (s, out, _) = mcp( + &rig.r, + &rig.token, + Some(&sid), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{ + "name": "lim_note__create", "arguments": {"title": "x".repeat(2001)}}}), + ) + .await; + assert_eq!(s, 200, "{out}"); + assert_eq!(out["result"]["isError"], true, "{out}"); + let text = out["result"]["content"][0]["text"].as_str().unwrap(); + assert!( + text.contains("lim_note.title must be at most 2000 characters; this has 2001"), + "{text}" + ); + assert_eq!(rig.count("lim_note").await, 0); + // And a call inside the limits works. + let (_, ok, _) = mcp( + &rig.r, + &rig.token, + Some(&sid), + json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{ + "name": "lim_note__create", "arguments": {"title": "x".repeat(2000)}}}), + ) + .await; + assert_ne!(ok["result"]["isError"], true, "{ok}"); + assert_eq!(rig.count("lim_note").await, 1); +} + +#[tokio::test] +async fn installing_a_package_with_a_limit_that_cannot_hold_is_refused() { + let rig = Rig::bare().await; + for (from, to, want) in [ + ( + "max_length = 2000", + "max_length = 0", + "`max_length` must be at least 1", + ), + ( + "max_length = 2000", + "max_length = 10001", + "above the largest a text field may declare", + ), + ( + "min_length = 1", + "min_length = 3000", + "`min_length` 3000 is above `max_length` 2000", + ), + ( + "min = 0\n max = 100\n", + "min = 5\n max = 4\n", + "`min` 5 is above `max` 4", + ), + ( + "min = \"0.1000000\"", + "min = 0.1", + "a TOML float is not exact", + ), + ] { + let bad = LIM.replace(from, to); + assert_ne!(bad, LIM, "{from}"); + let (s, b) = rig + .post("/api/module/install", json!({"manifest": bad})) + .await; + assert_eq!(s, 400, "{to}: {b}"); + assert!(error(&b).contains(want), "{to}: {b}"); + } + // Nothing was installed by the refused ones; the good package still installs. + assert!(!rig.app.dispatcher.has_model("lim_note")); + let (s, b) = rig + .post("/api/module/install", json!({"manifest": LIM})) + .await; + assert_eq!(s, 200, "{b}"); + assert!(rig.app.dispatcher.has_model("lim_note")); +} diff --git a/crates/obop/src/main.rs b/crates/obop/src/main.rs index b3c5210c..29e03f6d 100644 --- a/crates/obop/src/main.rs +++ b/crates/obop/src/main.rs @@ -28,6 +28,8 @@ mod import_cmd; #[cfg(test)] mod kinds_tests; #[cfg(test)] +mod limits_tests; +#[cfg(test)] mod mcp_tests; #[cfg(test)] mod mfa_tests; @@ -65,7 +67,7 @@ use std::process::ExitCode; use std::sync::Arc; const USAGE: &str = "usage: obop - --version the name, the core version and the commit the build was made from: obop 0.2.0 (abcdef012345) + --version the name, the core version and the commit the build was made from: obop 0.2.1 (abcdef012345) modules labels TARGET LANG a label template (every translatable text, English values) for a module, bundled package or FILE modules check [--complete] [--catalog] [FILE...] the install gate over linked modules, or over package files (no database): diff --git a/crates/obop/src/modules_tests.rs b/crates/obop/src/modules_tests.rs index 47f87357..d42302c1 100644 --- a/crates/obop/src/modules_tests.rs +++ b/crates/obop/src/modules_tests.rs @@ -58,7 +58,7 @@ fn every_existing_model_has_the_default_extensions() { #[test] fn the_core_version_is_the_workspace_version() { assert_eq!(obop_types::CORE_VERSION, env!("CARGO_PKG_VERSION")); - assert_eq!(obop_types::CORE_VERSION, "0.2.0"); + assert_eq!(obop_types::CORE_VERSION, "0.2.1"); let v = Version::parse(obop_types::CORE_VERSION).unwrap(); // The compatibility policy: while below 1.0 a minor bump is breaking, so ^0.2 is 0.2.x. assert!(Req::parse("^0.2").unwrap().matches(&v)); @@ -243,7 +243,7 @@ fn all_23_packages_install_with_their_dependencies_and_the_module_list_shows_eve by("projects")["dependents"], Value::List(vec![Value::text("timesheets")]) ); - assert_eq!(by("base")["version"], Value::text("0.2.0")); + assert_eq!(by("base")["version"], Value::text("0.2.1")); assert_eq!(by("base")["license"], Value::text("Apache-2.0")); let Value::List(base_dependents) = &by("base")["dependents"] else { panic!() @@ -408,7 +408,7 @@ fn the_tree_list_and_plan_commands_run() { "{t}" ); let t = app.installer.tree(Some("field_service")).unwrap(); - assert!(t.contains("base ^0.2 [built in, core 0.2.0]"), "{t}"); + assert!(t.contains("base ^0.2 [built in, core 0.2.1]"), "{t}"); assert!( t.contains("hr >=0.2, <1 [not installed, marketplace offers 0.3.0]"), "{t}" @@ -466,9 +466,9 @@ async fn the_core_version_is_served_and_an_agent_proposes_a_plan_that_one_approv let human = login.1["token"].as_str().unwrap().to_string(); // The one core version, everywhere it is reported. let meta = get(&r, &human, "/api/meta").await; - assert_eq!(meta["core_version"], "0.2.0"); + assert_eq!(meta["core_version"], "0.2.1"); let api = get(&r, &human, "/api/openapi.json").await; - assert_eq!(api["info"]["version"], "0.2.0"); + assert_eq!(api["info"]["version"], "0.2.1"); // The plan over HTTP, read-only. let (s, plan) = post( &r, @@ -480,7 +480,7 @@ async fn the_core_version_is_served_and_an_agent_proposes_a_plan_that_one_approv assert_eq!(s, 200, "{plan}"); let v = &plan["value"]; assert_eq!(v["ok"], true); - assert_eq!(v["core_version"], "0.2.0"); + assert_eq!(v["core_version"], "0.2.1"); assert_eq!(v["steps"][0]["id"], "projects"); assert_eq!(v["steps"][0]["license"], "Apache-2.0"); assert_eq!(v["steps"][1]["id"], "timesheets"); diff --git a/crates/obop/tests/limits_cli.rs b/crates/obop/tests/limits_cli.rs new file mode 100644 index 00000000..86f0786a --- /dev/null +++ b/crates/obop/tests/limits_cli.rs @@ -0,0 +1,147 @@ +//! `obop modules check` through the real binary on packages with field limits (D90): a good file +//! passes, and a bad one fails with the file, the model and field, and the key, so an author +//! knows where to look. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +struct Dir(PathBuf); + +impl Dir { + fn new(tag: &str) -> Dir { + let d = std::env::temp_dir().join(format!("obop-limits-cli-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Dir(d) + } + fn file(&self, name: &str, text: &str) -> PathBuf { + let p = self.0.join(name); + std::fs::write(&p, text).unwrap(); + p + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn package(core: &str, field: &str) -> String { + format!( + r#"[module] +id = "lim" +version = "0.1.0" +name = "Lim" +description = "Limits" +license = "MIT" +requires_core = "{core}" + +[[model]] +name = "lim_note" +description = "A note" +read_group = "base.user" +write_group = "base.user" + + [[model.field]] + name = "target" + description = "The field under test" + class = "none" +{field} +"# + ) +} + +fn check(files: &[&Path]) -> Output { + let mut c = Command::new(env!("CARGO_BIN_EXE_obop")); + c.env_clear().args(["modules", "check"]).args(files); + for keep in ["SystemRoot", "SYSTEMROOT", "PATH"] { + if let Ok(v) = std::env::var(keep) { + c.env(keep, v); + } + } + c.output().unwrap() +} + +fn said(o: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ) +} + +#[test] +fn a_package_with_limits_passes_and_the_bundled_ones_still_do() { + let dir = Dir::new("good"); + let good = dir.file( + "good.toml", + &package( + "^0.2.1", + " max_length = 120\n min_length = 2\n\n [[model.field]]\n name = \"qty\"\n description = \"Quantity\"\n class = \"none\"\n kind = \"number\"\n min = 0\n max = 99", + ), + ); + let o = check(&[&good]); + assert!(o.status.success(), "{}", said(&o)); + assert!(said(&o).contains("ok"), "{}", said(&o)); + // The shipped catalogue is untouched by the default length. + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let fleet = root.join("packages/fleet.toml"); + let o = check(&[&fleet]); + assert!(o.status.success(), "{}", said(&o)); +} + +#[test] +fn a_bad_limit_fails_the_run_and_names_the_file_the_field_and_the_key() { + let dir = Dir::new("bad"); + for (keys, want) in [ + (" max_length = 0", "`max_length` must be at least 1"), + ( + " max_length = 10001", + "above the largest a text field may declare", + ), + ( + " min_length = 5\n max_length = 4", + "`min_length` 5 is above `max_length` 4", + ), + ( + " kind = \"number\"\n max_length = 4", + "only valid for kind \"text\" or \"longtext\"", + ), + (" kind = \"number\"\n min = 0.5", "must be a whole number"), + ( + " kind = \"decimal\"\n precision = 10\n scale = 2\n max = 1.5", + "a TOML float is not exact", + ), + ( + " kind = \"decimal\"\n precision = 10\n scale = 2\n max = \"1.234\"", + "is not a decimal that fits decimal(10, 2)", + ), + ( + " kind = \"number\"\n min = 5\n max = 4", + "`min` 5 is above `max` 4", + ), + (" maxlength = 4", "unknown field"), + ] { + let file = dir.file("bad.toml", &package("^0.2.1", keys)); + let o = check(&[&file]); + assert!(!o.status.success(), "{keys}: {}", said(&o)); + let out = said(&o); + assert!( + out.contains("FAIL") && out.contains("bad.toml"), + "{keys}: {out}" + ); + assert!(out.contains(want), "{keys}: wanted {want:?} in {out}"); + // The field is named (not for a key the parser does not know, which names its position). + if !keys.contains("maxlength") { + assert!(out.contains("lim_note.target"), "{keys}: {out}"); + } + } + // One bad file fails the run even next to a good one. + let good = dir.file("good.toml", &package("^0.2.1", " max_length = 10")); + let bad = dir.file("bad.toml", &package("^0.2.1", " max_length = 0")); + let o = check(&[&good, &bad]); + assert!(!o.status.success()); + let out = said(&o); + assert!(out.contains("ok") && out.contains("FAIL"), "{out}"); +} diff --git a/crates/obop/tests/version.rs b/crates/obop/tests/version.rs index 5644d8bb..edf723c5 100644 --- a/crates/obop/tests/version.rs +++ b/crates/obop/tests/version.rs @@ -26,7 +26,7 @@ fn version(args: &[&str]) -> String { String::from_utf8(out.stdout).unwrap() } -/// `obop 0.2.0 (abcdef012345)`: the commit between the parentheses, or `unknown`. +/// `obop 0.2.1 (abcdef012345)`: the commit between the parentheses, or `unknown`. fn commit_of(line: &str) -> &str { let rest = line .strip_prefix(&format!("obop {} (", env!("CARGO_PKG_VERSION"))) @@ -38,7 +38,7 @@ fn commit_of(line: &str) -> &str { #[test] fn the_version_line_keeps_its_prefix_and_ends_in_the_commit() { let line = version(&["--version"]); - assert!(line.starts_with("obop 0.2.0 ("), "{line:?}"); + assert!(line.starts_with("obop 0.2.1 ("), "{line:?}"); assert_eq!(line.lines().count(), 1, "{line:?}"); let commit = commit_of(&line); assert!( diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index b4630edb..91b7a00c 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -519,6 +519,7 @@ These stay open. Documents keep "Proposed" wording and cite the question; this f | `@obop/*` SDK package names and whether obop publishes anything to the npm registry | The names of the generated TypeScript SDKs and the UI SDK; the default (Q32) is no npm-registry publication, decided at W2 (OR-19), so the scope may never exist | Q32, OR-19, D3, D80 | | The spellings of the reserved actor ids that tracked records store in `created_by` and `updated_by` (`system:internal`, `public:anonymous`, `connector:`; each contains a colon, which no login can), the use of the existing group `audit.reader` for the global change feed, and whether an installed module that already uses one of the six reserved system field names (`created_at`, `updated_at`, `created_by`, `updated_by`, `row_version`, `sys_backfilled`) is grandfathered or blocks the upgrade | The values stored in every tracked record, and the field names no package may declare (the gate refuses them with a rename hint; built) | Q44, Q45, D75 | | The spellings that D88 and D89 propose (none is built): the manifest table `[[model.rule]]` and the field key `on_delete` (values `refuse`, `cascade`, `set_null`); the error body key `assert_scope` (value `model.`); the error code `delete_blocked` (status 409); the audit kind `delete_cascade` of `security_event`; the body keys `references`, `reason` and `limit` of `delete_blocked`; the `rules` key and the `on_delete` key of `/api/meta`; the label slot `rule..message`; the upgrade-diff change kind `field_on_delete`; and the setting `OBOP_DELETE_MAX_ROWS` | The package format of D52, the error table, the audit kinds, the metadata of D46 and the settings reference of DEPLOY.md | D88, D89; the setting's default is ratified as a starting value (OR-35 d) | +| The spellings that D90 proposes (built, in review): the field keys `max_length`, `min_length`, `min` and `max`; the same four keys in `/api/meta`; the error code `limit_violated` (status 400); the upgrade-diff change kind `field_limits`; the OpenAPI extension keywords `x-obop-minimum` and `x-obop-maximum` (for a decimal, which is a string); and the four constants `DEFAULT_TEXT_MAX`, `DEFAULT_LONGTEXT_MAX`, `TEXT_MAX_CAP` and `LONGTEXT_MAX_CAP` of `obop-registry` `limits.rs`, whose values (2,048, 100,000, 10,000, 1,000,000) are proposals for the product owner | The package format of D52, MODULES.md 3.3 and 3.5, the error table, the metadata of D46 and the OpenAPI document | D90 | ### 9.2 Proposed rulings awaiting the owning spec diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d103e714..25f13ebb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -224,12 +224,15 @@ Interpretations, all RATIFIED (I-1 by OR-14, I-2 and I-4 by OR-9, I-3 by OR-8): | D87 | Native apps (React Native clients) | RATIFIED (OR-34) for parts (a) to (e): React Native (Expo) allowed as an obop client, the mobile-toolchain exception, the manual baseline, Transistorsoft not the default, Expo SDK 57 for now, amended on 2026-10-28 to SDK 58 if its packages pass the cooldown when the app's build starts; the scope limits and the technical design PROPOSED (Q37 to Q43) | React Native (Expo) apps may be obop clients (OR-34); the proposal limits them to first-party apps and to cases a PWA cannot serve (Proposed), keeps the PWA as the default and starts with Mileage (a `mileage` package and an app that records a trip while the phone is locked); manual start and stop is the baseline; the mobile toolchain is a third named exception to "`vp` only"; that Apple and Google accounts, store listings and publication stay the owner's is Proposed (Q37) | | D88 | Model rules (`[[model.rule]]`) | PROPOSED; nothing built; four points RATIFIED: delete is not covered by model rules, for now (OR-35 f), the amendment of MODULES.md 3.1 for patch releases (OR-35 h), API fields and field descriptions in patch releases (OR-35 j), and the generated texts that tell agents what each operation does in patch releases (the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions; OR-35 k). Asks 11.2 and 11.5 of the platform team. Q57 and Q58 were answered by the platform team on 2026-10-28 (platform answers, not owner rulings) and Q61 by the owner (OR-35 f); all three are closed | A package may declare up to 8 rules on a model: a key, a Bool expression over the record, one parent field reached through a `ref`, `id` and (in slice 2) `now()`, an English message and when it applies. Every write that passes the engine's unit (create, update, each row of `bulk_create`, the effects of actions) is refused when a rule is false. The refusal reuses `assertion_failed` and `assert_key` and adds `assert_scope` (a lead decision, not an owner ruling); delete is not covered. Each commit that widens the manifest parser takes the next patch number of the 0.2 line (two for D88, two for D89), not 0.3.0 (a lead decision; OR-35 h, j and k rule only the amendment of MODULES.md 3.1, without the listing condition for the texts of OR-35 k (a third lead decision, of 2026-10-29, stated in Decision 14), and that the bundled packages keep `^0.2`) | | D89 | Delete rules on references (`on_delete`) | PROPOSED; nothing built; RATIFIED (OR-35 a to e, g, h, j and k) for nine points: the leaks that remain are accepted, a human approval of an agent's delete also approves its cascade, the interim audit sink, the starting cap of 2,000 rows (ceiling 4,000) with its bench, `cascade` before D79's DELETE position, the approver view that hides an edge into a model the approver cannot read, the amendment of MODULES.md 3.1 for patch releases, API fields and field descriptions in patch releases, and the generated texts that tell agents what each operation does in patch releases (the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions). Ask 11.6 of the platform team. Q59 (the cap) and Q60 (who may delete) are closed by the owner | A `ref` field may declare `refuse` (the default and today's behaviour), `cascade` or `set_null`. The engine applies the closure under its own authority in one transaction with audit entries, up to a cap (2,000 rows by default, ceiling 4,000: OR-35 d). A refusal names only the rows the viewer may list and gives one neutral text for every other cause. Amends D49. The refusal becomes 409 `delete_blocked` (a lead decision) and an approver view is added (OR-35 g rules that it hides edges into models the approver cannot read) | +| D90 | Field limits (`max_length`, `min_length`, `min`, `max`) | PROPOSED; built on the branch `feat/kinds-limits`, in review; nothing ratified; the default lengths and the caps are the owner's to rule before the merge. Ask 11.3 of the platform team | `max_length` and `min_length` on `text` and `longtext`, counted in code points and not bytes; `min` and `max` (inclusive, exact) on `number`, `money` and `decimal`. Refused with 400 `limit_violated` (a new code, kept when an action effect meets a limit) on `create`, `update` and action effects, on REST, MCP and the CLI, naming the field and the limit, and what was written only to a person who typed it (never to an agent or from an effect). The other writers refuse in their own way: a row of `bulk_create` or an import is named without the code (a 400 only in `all_or_nothing`), a record that a connector pushes in is rejected as one item, and a public submit is rejected as a whole with the text of a direct write and no code (the site shows a generic error). A default length for a field that declares none (**2,048** for `text`, **100,000** for `longtext`: proposals) and a hard cap for an override (**10,000** and **1,000,000**: proposals), named constants in one place; a value equal to the stored one is not re-checked on `update`, so a lowered limit locks no row. The install gate refuses a limit that cannot hold; the 23 bundled packages install unchanged. Shown in `/api/meta`, OpenAPI, MCP schemas, `obop modules check` and a counter in the back office form. `CORE_VERSION` becomes 0.2.1 by the version rule of D88 Decision 14 (a package that declares a limit writes `requires_core = "^0.2.1"`; the bundled packages keep `^0.2`). A new refusal where there was none is a visible difference that OR-35 h does not name (Decision 9) | -Records D88 and D89 (2026-10-28) are designs for asks 11.2, 11.5 and 11.6 of the platform team, a project that builds on obop. Nothing in them is built. Ratified on 2026-10-28 and 2026-10-29 by OR-35 (relayed; parts (a) to (f) of 2026-10-28, parts (g) to (k) of 2026-10-29), each for the points the OR-35 row names and nothing wider: D89 is PROPOSED except nine points that the owner ruled (OR-35 a to e: Decision 9 on the leaks that remain, the human-approver rule of Decision 5, the interim audit sink of Decision 8, the starting cap and ceiling of Decision 7 with the bench at both, and `cascade` before D79's DELETE position in Decisions 2 and 12; OR-35 g: the approver view of Decision 5 hides an edge into a model the approver cannot read; OR-35 h: the amendment of MODULES.md 3.1, Decision 15; OR-35 j: API fields and field descriptions in patch releases, Decision 15; OR-35 k: the generated texts that tell agents what each operation does in patch releases (the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions), Decision 15); D88 is PROPOSED except four points (OR-35 f: delete is not covered by model rules, for now; OR-35 h: the same amendment, Decision 14; OR-35 j: API fields and field descriptions, Decision 14; OR-35 k: the generated texts that tell agents what each operation does (the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions), Decision 14). OR-35 i confirms the gloss of `D11/6.3/DPO` in the Q59 row. Lead decisions, not owner rulings, which stay PROPOSED: for D88, three (the reuse of `assertion_failed` and `assert_key` with the additive `assert_scope`, and the mechanism of the version rule, both of 2026-10-28, and the listing condition for the texts of OR-35 k, of 2026-10-29); for D89, three (the change of the refused delete from a bare 400 to 409 `delete_blocked`, and the same mechanism, both of 2026-10-28, and the same listing condition). The mechanism of the version rule: each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line, four in all (D88 Decision 14, D89 Decision 15), with the `^0.2.N` spelling, the tag form and the test names; the owner ruled (OR-35 h, j and k) only the amendment of MODULES.md 3.1 (without the listing condition for the texts of OR-35 k, which is a lead decision) and that the 23 bundled packages keep `^0.2`. Q57 and Q58 are closed by answers of the platform team and Q59, Q60 and Q61 by the owner (OR-35 d, e, f); no question of these records is open. The platform team's answers (Q57, Q58 and its figures for the cap) are platform answers and not owner rulings. +Record D90 (2026-10-29) is the design and the build of ask 11.3 of the platform team, a project that builds on obop (the package "kinds-limits"). It is PROPOSED throughout: no owner ruling covers any part of it. The numbers of Decision 3 (the default and the cap of each text kind) are proposals that go to the product owner before the merge. What D90 says about the version rule (that it takes 0.2.1 if it lands first, and that a new refusal where there was none is listed as a visible difference) applies the mechanism of D88 Decision 14, which stays PROPOSED apart from what OR-35 h, j and k rule, and adds no owner ruling. It raises two points for the owner (the four numbers of Decision 3; whether a patch release may add a refusal where there was none, Decision 9) that are not registered as questions in section 8, and the counts below do not include them; K43 tracks both. + +Records D88 and D89 (2026-10-28) are designs for asks 11.2, 11.5 and 11.6 of the platform team, a project that builds on obop. Nothing in them is built. Ratified on 2026-10-28 and 2026-10-29 by OR-35 (relayed; parts (a) to (f) of 2026-10-28, parts (g) to (k) of 2026-10-29), each for the points the OR-35 row names and nothing wider: D89 is PROPOSED except nine points that the owner ruled (OR-35 a to e: Decision 9 on the leaks that remain, the human-approver rule of Decision 5, the interim audit sink of Decision 8, the starting cap and ceiling of Decision 7 with the bench at both, and `cascade` before D79's DELETE position in Decisions 2 and 12; OR-35 g: the approver view of Decision 5 hides an edge into a model the approver cannot read; OR-35 h: the amendment of MODULES.md 3.1, Decision 15; OR-35 j: API fields and field descriptions in patch releases, Decision 15; OR-35 k: the generated texts that tell agents what each operation does in patch releases (the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions), Decision 15); D88 is PROPOSED except four points (OR-35 f: delete is not covered by model rules, for now; OR-35 h: the same amendment, Decision 14; OR-35 j: API fields and field descriptions, Decision 14; OR-35 k: the generated texts that tell agents what each operation does (the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions), Decision 14). OR-35 i confirms the gloss of `D11/6.3/DPO` in the Q59 row. Lead decisions, not owner rulings, which stay PROPOSED: for D88, three (the reuse of `assertion_failed` and `assert_key` with the additive `assert_scope`, and the mechanism of the version rule, both of 2026-10-28, and the listing condition for the texts of OR-35 k, of 2026-10-29); for D89, three (the change of the refused delete from a bare 400 to 409 `delete_blocked`, and the same mechanism, both of 2026-10-28, and the same listing condition). The mechanism of the version rule: each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line, four in all (D88 Decision 14, D89 Decision 15; D90 Decision 9 adds a fifth, the keys of the field limits, and says that the first of the five to land is 0.2.1), with the `^0.2.N` spelling, the tag form and the test names; the owner ruled (OR-35 h, j and k) only the amendment of MODULES.md 3.1 (without the listing condition for the texts of OR-35 k, which is a lead decision) and that the 23 bundled packages keep `^0.2`. Q57 and Q58 are closed by answers of the platform team and Q59, Q60 and Q61 by the owner (OR-35 d, e, f); no question of these records is open. The platform team's answers (Q57, Q58 and its figures for the cap) are platform answers and not owner rulings. Ratified on 2026-10-01 (owner rulings OR-1 to OR-21) and 2026-10-27 (OR-22, OR-34): D1, D2, D3, D4 (country), D5 (superseded), D16 (superseded), D17, D23, D24 (the renames and the X and L prefixes, OR-14), D26 (the German date; the windows and the 1.0 target as kept, on the reading in D26), D80 to D86 (the rulings' own content: for D80 literal UI parity and the exemption-list mechanism, OR-16; for D81 the OSI-licence listing rule and the meaning of verified, OR-17; for D82 the owner's sign-off on every commit with agents recorded by trailer, OR-18; for D83 the hosted sandbox, OR-20; for D84 the no-grant-application position, OR-21; for D86 the W1 cut, OR-15), interpretations I-1 to I-4 (I-1 by OR-14), the exit-drill exception of D15, and D87 for parts (a) to (e) of OR-34 (React Native allowed as a client, the mobile-toolchain exception, the manual baseline, Transistorsoft not the default, Expo SDK 57). Q32 (npm-registry publication) is not ruled: OR-19 defers it to W2 with the default of no publication. -Proposed and awaiting the owner: the scope limits of D87 (first-party apps only, only where a PWA cannot do the job, the PWA stays the default, Tauri mobile is not pursued), the product and works-council sign-offs for auto-stop, and the technical design of D87 (the mobile client class of D64, the `device` model, the device outbox of D13, the `mileage` package, the privacy points; Q37 to Q43), D40, D41, D42 and D43 (Q21), the PROPOSED owner-directed records of the build (D66 to D70, D72 to D79) and D88 and D89 apart from the points ruled by OR-35, D4 (ICP, the T0 list, France and Poland), D12, D13, D14, D20, D25 (T0 list, Q8), the T0-T3 labels of D24, the staging and the remaining admission details of D81 (Q24, Q25), the release cadence and the deprecation window after 1.0 in D85 (Q28), the sandbox details of D83 (terms and data policy, host and cost ceiling, abuse and screening approach: Q34 to Q36), the CLI grammar and output conventions of D80 (Q22) and the owner's approval of each entry on the exemption list (OR-16; the entries of spec/cli.md 1.1 are proposals until approved), the slip order, checkpoints and landing waves of D86 (Q8), the npm-registry publication of Q32 (decide at W2, OR-19; default: none), and the remaining dates of D26 (the 0.x releases and the FR and PL pack dates). +Proposed and awaiting the owner: the scope limits of D87 (first-party apps only, only where a PWA cannot do the job, the PWA stays the default, Tauri mobile is not pursued), the product and works-council sign-offs for auto-stop, and the technical design of D87 (the mobile client class of D64, the `device` model, the device outbox of D13, the `mileage` package, the privacy points; Q37 to Q43), D40, D41, D42 and D43 (Q21), the PROPOSED owner-directed records of the build (D66 to D70, D72 to D79), D88 and D89 apart from the points ruled by OR-35, and D90 (the field limits; the default lengths and caps first), D4 (ICP, the T0 list, France and Poland), D12, D13, D14, D20, D25 (T0 list, Q8), the T0-T3 labels of D24, the staging and the remaining admission details of D81 (Q24, Q25), the release cadence and the deprecation window after 1.0 in D85 (Q28), the sandbox details of D83 (terms and data policy, host and cost ceiling, abuse and screening approach: Q34 to Q36), the CLI grammar and output conventions of D80 (Q22) and the owner's approval of each entry on the exemption list (OR-16; the entries of spec/cli.md 1.1 are proposals until approved), the slip order, checkpoints and landing waves of D86 (Q8), the npm-registry publication of Q32 (decide at W2, OR-19; default: none), and the remaining dates of D26 (the 0.x releases and the FR and PL pack dates). ### 2.2 Records @@ -1200,6 +1203,23 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point **Why.** A mileage report needs the route while the phone is in a pocket, which a PWA cannot promise (general knowledge), and a native client is the smallest thing that can. React Native with Expo keeps TypeScript, the pnpm workspace and the shared client and token packages the same as the web back office, and `expo-location` needs no paid library. Keeping the app a plain client keeps D80: nothing is app-only. A manual baseline avoids ending a trip the driver did not end, which is a labour-law and data-protection question. **Rejected.** A PWA alone (cannot record in the background; general knowledge); Capacitor (not React Native; a prior mileage app used it and its first real drive exposed seven bugs, so it is evidence and not a precedent); Tauri mobile (maturity unverified, R07 s3.9); Transistorsoft as the default (a paid key in the build path of the first app); AsyncStorage or browser storage for raw points (can be cleared under storage pressure and holds raw GPS unencrypted); an embedded web view for sign-in (RFC 8252); trusting a distance the client computed; automatic trip end in the baseline; third-party geocoding or map tiles by default. **Consequences.** The non-goal "native store apps beyond the PWA and Tauri shells" is replaced in PRODUCT, ux 8, people 8 and ARCHITECTURE 10.5; D13 and D64 each carry an amendment; "exactly two exceptions" becomes three named exceptions in CLAUDE.md and the documents that repeated it (OR-9 still rules what it ruled; OR-34 adds one). The tooling, the layout (`web/packages/client`, `web/packages/tokens`, `web/packages/trip-core`, `web/apps/mileage-app`, `packages/mileage.toml`) and the first slice are in spec/mobile.md 8 and 9; the risks (Android vendor battery limits, Google Play and App Review of background location, GPS that emulators cannot fully test, SDK churn, location-data compliance) are in 13. **Evidence.** OR-34; the design proposal of 2026-10-27 written by the lead session, whose markings (verified, general knowledge, unverified) spec/mobile.md keeps; D13, D15, D23, D31, D35, D37, D54, D64, D65, D70, D72, D75, D80; RFC 8252. +#### D90 Field limits: `max_length`, `min_length`, `min` and `max`, and a default length for text +**Status** PROPOSED (a design and its build of 2026-10-29 for ask 11.3 of the platform team, a project that builds on obop; built on the branch `feat/kinds-limits`, in review; the two default lengths and the two caps of Decision 3 are the product owner's to rule before the merge; not ratified as binding). **Reversibility** the manifest keys and the error code are a one-way door once a package or a client uses them (an older core refuses a package that declares a key it does not know, and a client may branch on `limit_violated`). The numbers are a two-way door in one direction only, and they are cheapest to change before the first release that ships the keys. Raising a default or a cap later breaks nothing. Lowering one makes writes fail that used to work (Decision 6, a stored value is never refused for being there, is what keeps a lowered limit from locking existing rows) and does more than that: `limits::problems` runs in `check_installed_module` and in `Manifest::field_def`, which `Installer::load_installed` calls for every installed package at every start, so a package that is already installed and declares a `max_length` above a lowered cap, or a `min_length` above a lowered default with no `max_length`, is refused at the next start, lands in the `failed` list and registers none of its models, which is the hazard that D89 Decision 15 describes for an image rollback, here caused by a newer core (found at the review of PR 33, F4, and reproduced there: with the text cap lowered to 5,000, a package at `max_length = 8000` did not load). Nothing in this record exempts an installed package from this gate; that would need an installed-or-install flag in both places and is not done. The one exemption is narrower and is in Decision 7: the checks of a `set_default` step of a migration are not repeated for a package that is already installed. **Owner action** rule on the numbers of Decision 3, and on whether a patch release may add a refusal that OR-35 h does not name (Decision 9). These are two owner points that D90 raises and that are not registered as questions in section 8; the question counts of this document do not include them, and K43 tracks both. + +**Source.** Ask 11.3 of the platform team, "length rules on text fields" (the requirements DAT-060 and MTG-N4 of the platform's data requirements): a `max_length` and optionally a `min_length` on `text` and `longtext`, counted in characters (code points) and not bytes, refused on `create`, `update` and `bulk_create` with a 400 that names the field and the limit, visible in `/api/meta`, a default upper bound for a field without one, and a gate that refuses a limit of 0 and one above the limit the platform allows. The platform's evidence: a post body may be 2,000 characters and a comment 1,000 in the originals, and obop accepted a body of 2,001, of 2,000,000 and a one-line field of 70,000 characters; nothing but the client enforced the limit and the client is not trusted. The same ask notes that a `required` text field accepts `""` and `" "`. The platform's queue says that 11.3 "joins the queued kinds-limits package", needs an amendment of the package format (D52 and MODULES.md) and "a documented default upper bound, with a per-field override up to a hard cap"; the table row of the ask says the gate refuses a limit "above the default", where this record, following that queue note, puts the line at the cap (Decision 3). + +**Built today, and what this record changes.** Before this record no text had a length limit at any surface (the only bound was the request body limit of the HTTP server, 2 MiB), and `number`, `money` and `decimal` had no range. `required` meant present and non-null. The kinds `decimal` and `datetime` and their canonical text are D72; the check of a field's kind runs in one place for every write (`check_fields_against` in `crates/kernel/obop-commands/src/engine/crud.rs`), with a second, smaller check for the public submit (`store::validate_with`). Nothing else is added by this record: no new kind, no new storage, no change to what is stored. + +**Decision.** *1. The keys.* In a package manifest, on `[[model.field]]`: `max_length` and `min_length` (whole numbers, 1 or more) for `kind = "text"` and `"longtext"`; `min` and `max` (inclusive) for `"number"` and `"money"` (whole numbers; money is in minor units) and for `"decimal"` (a string such as `"0.10"` or a TOML integer, with at most as many fractional digits as the scale of the field and within its precision; fewer digits are padded to the scale, so `min = 0` on a `decimal(8, 2)` is `0.00`; a TOML float is refused because it is not exact, and so is a value with more fractional digits than the scale or beyond the precision; every surface shows the bound in the canonical spelling of the API, a string at the scale). Accepting a whole number as a decimal bound is a lead decision of the review of PR 33. The registry holds them in `FieldDef.limits` (`crates/kernel/obop-registry/src/limits.rs`), so the registry, the X1 manifest (`FieldM`, still `deny_unknown_fields`), `/api/meta`, OpenAPI, MCP and the CLI all read one definition. No other kind takes a limit (bounds on `date` and `datetime`, `pattern` and the like are not delivered, below), and a computed or numbered field takes none because it is never written. *2. What a length counts.* Unicode scalar values (`str::chars`), never bytes and never what a person sees as one character: `å`, `ä`, `ö` and U+1F600 count 1 each, an `e` with a combining accent counts 2, a flag or a family emoji counts as many code points as it is made of. Nothing is trimmed or normalised before counting. A string of `n` bytes has between `n/4` (rounded up) and `n` characters, so most values are decided from the byte length alone and only a value between the two bounds is counted. The browser counts the same way with a loop that is aware of surrogate pairs (`codePoints` in `web/apps/backoffice/src/limits.ts`), not with `.length`, which counts UTF-16 units. *Whitespace, and `required`.* `min_length = 1` refuses `""` and accepts `" "`: a length is what was sent. `required` is unchanged (it still accepts `""`); `min_length` does not make a field required either, because it applies to a value that is written, and a missing or null value is not checked (the back office treats an emptied field as absent, as it always did). For the platform's "a post of spaces" the answer is therefore no: `min_length = 1` closes `""` and not a value of spaces; refusing a blank text is a different rule (a trim or a pattern) and is listed under Not delivered, K45. *3. The default and the cap (the numbers below are proposals for the owner; they are constants in one place, `limits.rs`, and nowhere else).* A `text` field with no `max_length` has `DEFAULT_TEXT_MAX` = **2,048** characters and a `longtext` field `DEFAULT_LONGTEXT_MAX` = **100,000**; a field may declare its own `max_length` up to `TEXT_MAX_CAP` = **10,000** (text) and `LONGTEXT_MAX_CAP` = **1,000,000** (longtext), and the gate refuses more. Why these numbers (round numbers with a margin over what was measured, not figures derived from a standard; this wording is a lead decision of the review of PR 33, made because the first wording claimed more of its sources than they say): the longest `text` value in the seed data of a fresh instance is 140 characters and the longest `longtext` value 2,040 (Consequences), so 2,048 and 100,000 leave a margin of about fifteen and about fifty times. 2,048 is also the conservative ceiling for a URL, the longest ordinary one-line value: the sitemaps.org protocol asks for fewer than 2,048 characters in a URL and old Internet Explorer stopped near that length (modern browsers accept far more, so this is a conservative ceiling and not a limit of today's browsers), and it is this project's own limit for an OAuth redirect URI (`obop-oauth` `redirect.rs`). Names, titles, addresses and codes are far shorter; a field that holds a list in one line declares its own. 100,000 is about fifty printed pages. For scale only, two unofficial third-party figures that were not checked against current documentation: GitHub is said to refuse an issue body above 65,536 characters (from its error message; it is not documented), and mail clients are reported to clip an HTML mail near 102,400 bytes. They are analogies and not sources. The caps leave room above the defaults (about five times and ten times) and are low enough that a limit still means something; the body limit of the HTTP server (2 MiB) applies first, so text of three or four bytes a character reaches it before it reaches a cap of 1,000,000. The default applies to every written `text` and `longtext` field of every package and of the built-in modules, so it is a behaviour change for existing instances (Decision 9); the survey in Consequences says what was and was not measured. *4. Where it is checked.* On every write that checks field kinds: `create` and `update` (`crud.rs::check_fields_against`, called by `create` and `update`), each row of `bulk_create` and so `obop import` and the Import dialog (`bulk.rs::create_row` calls `check_fields`), what an action effect writes (`effects.rs` calls `check_effect_fields` for a `create` effect and for a `change` effect: the same limits, and a refusal that never states the length or the value, Decision 5), the check that runs before the actions of an update that moves a governed state (`guards.rs`; it compares with the stored record like `update`, so an unchanged over-limit value sent along with the state move passes, and a changed one is refused before the state moves and before any effect runs, F1), the REST, MCP and CLI front ends of all of these (they all go through the Dispatcher), the public submit of `[model.public]` forms (`public.rs` calls `store::validate_with`; the check is `store::check_value`, for text, number and decimal), and the `set_default` value of a `[[migration]]` step (`obop-pkg` `manifest.rs::limits_hold`; for a `number` or `money` field the value is a TOML integer, and a float is refused as it is by a write, F19; both checks run when a package is installed, uploaded, previewed or upgraded to, and not when an installed package is read again, Decision 7). A computed or numbered field is never checked. *Inbound connector records are checked* (F9): `obop-connectors` `ingress.rs::accept_one` writes each pushed record through the Dispatcher (`{model}.create` or `{model}.update`), so the limits apply to it, the default included. A breach rejects that item and not the delivery: the other items of the delivery are kept, the run record is `partial` (or `failed` when every item was rejected) and keeps the first message only (there is no dead letter per item), the answer to the sender is 202 with the counts when at least one item was accepted and 422 with the refusal text when all were rejected, and the event id is already claimed, so the sender's retry of the same event is taken for a replay. That is how every rejection of an inbound item behaves; D90 adds a cause. D90 does not change the ingress (a lead decision of the review of PR 33); the test is `an_inbound_item_over_a_limit_is_rejected_by_itself_and_the_rest_are_kept`. *Not covered* is a record that a Rust handler writes straight to the store (the partial updates that the connectors runtime makes to a connection, and its consents and sync runs; a connection itself is created and updated through the generic `create` and `update`, so its limits apply; and the control plane, the forum, the user administration, the installer's records, K44). *Effect maps are checked against the limits only when the action runs* (F5, a lead decision: no install-time check in this record): the install gate checks the kind of an effect's values and not their limits, so an effect that writes a constant outside the limits of its target, or copies a longer field (`$self.body`, a `longtext`) into a shorter one, installs and is refused when the action runs, with `limit_violated` and nothing written (K44). *5. The refusal.* HTTP 400 with the new code `limit_violated` (`errors::TABLE`, origin D90; REST, MCP and the CLI show the same text, the CLI as `error: Handler("limit_violated: ...")`, MCP as an error result with the body). To a person who typed the value directly the message names the model, the field, the limit and what was written: `lim_note.headline must be at most 2000 characters; this has 2001`, `lim_note.headline must be at least 1 character; this has 0`, `lim_note.qty must be at most 100; this is 101`, `lim_note.rate must be at most 9.99; this is 10.00`. It never repeats the text itself: it names the field and the limit and, to a person who typed the value, how long it was or what number was written. Nothing is written (a refusal leaves the store alone, as every other 400 of `create` does). *The count is withheld where the value was not the caller's own typing* (F7, a lead decision of the review of PR 33): when the caller is an agent (`Actor.agent`, a principal of kind agent), and whenever the refusal comes from an action effect, the message says only what the field takes: `lim_note.headline must be at most 2000 characters`, `lim_note.qty must be at most 100`. The reason is that the dispatcher restores a mask token (`NAME_5`) to the real value before a handler runs, so "this has 40" would tell an agent how long the personal value behind its token is, and an effect that copies `$self.salary` into a field the caller may not read would state that number to a caller who may not see it. This covers the direct writes of an agent (create, update, bulk_create, a number or decimal bound), the effects of every action (`check_effect_fields`) and the check before a governed state move when the caller is an agent. A person who writes directly and a visitor of a public submit (who typed the value) keep the count. *Through an effect* (F0 and F8, a lead decision): the effect wrapper adds its context ('{model}.{action}: effect N (create) failed, nothing was changed: ...', and for a nested state move '{target}.{action} on the related record: ...'); when the inner error carries `limit_violated` the wrapper keeps the code first (`errors::wrap`), so the answer is classified as `limit_violated` with its hint on REST (400 with `code`), MCP and the CLI, for a `create` effect, an `adjust` effect and a nested `set_state` action; an inner error with any other code, and every inner error without one, is wrapped as before. *A bulk row and a CSV row* are named without the code: `row N: ...`, the plain text of `errors::plain`, which drops the code of the table. A `bulk_create` call in the default `all_or_nothing` mode that meets a refused row answers a 400 that names the first five rows and carries no `limit_violated` code, and creates nothing; in `partial` mode, and with `dry_run`, the answer is an ordinary success whose `errors` list names every refused row (up to the listing cap) and `partial` keeps the rows that are fine; a CSV import says `row N (line L): ...`. *A record pushed in by a connector* is rejected as that one item (Decision 4), not with a 400. *A public submit* (`PublicApi::submit_batch` and `submit_order`) rejects the whole batch with `SubmitError::Rejected`, which carries the text of a direct write, count included (the visitor typed the value), without the code (`errors::plain`); it has no HTTP status of its own, and the two places that call it, the contact form of the public site and the shop checkout, show the visitor only "Something went wrong. Please try again." (`err.generic`; `obop-site` `content.rs`, `checkout.rs`), so the visitor is not told the field or the limit (K45). *6. A value that was stored before the limit.* A limit is checked when a value is written. On `update`, a value equal to the one stored is not checked again against the limits, so a record that holds 2,500 characters in a field that now allows 2,000 can still have its other fields edited, and a form that sends the whole record back does not fail for a field the person did not touch; a decimal is equal by value and not by spelling (a stored `100.50` and a resent `100.5` are the same value, F10); a value that differs from the stored one is checked even when it is shorter than before but still too long, and every other check (kind, `required`, unique, refs, assertions) runs as always. The back office does the same (it compares with what it loaded). Existing rows are never re-validated, and an upgrade that lowers a limit does not scan them (K46). *7. The install gate (D41).* `limits::problems`, run by `check_basics` for built-in modules and packages alike: refused, naming the field and the key: `max_length` 0; `min_length` 0; a `max_length` above the cap of the kind; `min_length` above `max_length`, or above the default when no `max_length` is declared (the field could never hold a value); `max_length` or `min_length` on any kind but `text` and `longtext`; `min` or `max` on any kind but `number`, `money` and `decimal`; a bound of the wrong shape (a decimal for a number; for a decimal, a TOML float, a value with more fractional digits than the scale, a value beyond the precision); `min` above `max`; a limit on a computed or numbered field; an unparseable bound (a TOML float, a string that is not a decimal). For a `min_length` that no value can meet, the refusal names the way out (F20): above `max_length` it says so; with no `max_length` and above the default it advises declaring a `max_length` of at least that many and at most the cap, naming the cap; with no `max_length` and above the cap of the kind it says that no `max_length` can help and, for a `text` field, to use a `longtext` field for more (up to 1,000,000). The registry gate also refuses an integer bound on a decimal field; a package cannot reach that arm, because the manifest parser reads a TOML integer for a decimal as the decimal at the scale of the field, but an in-code caller of `FieldDef::with_limits` can, and the arm is covered by `the_gate_refuses_bounds_that_do_not_fit_the_kind_or_each_other`, so it stays (the lead's decision at the review of PR 33: delete it only if it could never fire). *Installed manifests are not re-refused* (a lead decision of the second round of the review of PR 33): the refusal of a float `set_default` into a `number` or `money` field and the check of a `set_default` value against the limits of its field run when a package is installed, uploaded, previewed or upgraded to, and not when the stored text of an installed package is read again (at start, and for the stored side of an upgrade or a rollback), because the previous core accepted such a package and a server must still start with the module its data belongs to (`default_matches` takes `installed`). It is the only exemption of an installed package in this record; the field gate above is not relaxed (Reversibility). All 23 bundled packages and the built-in modules pass unchanged (`obop modules check packages/*.toml`: 23 ok); none declares a limit. *8. Surfaces (D80).* `/api/meta`: `max_length` on every written text field (the default included, so a client always knows the line), `min_length`, and `min` and `max` (decimals as canonical strings), none on a computed or numbered field. OpenAPI: the keywords `maxLength`, `minLength`, `minimum` and `maximum` on the input schemas of the write commands only (decimals as `x-obop-minimum` and `x-obop-maximum`, because a decimal is a string and `minimum` would be wrong for a string), none on record schemas (a stored value above a lowered limit is legal, Decision 6). MCP tool schemas: the same keywords, except for a field of personal data, whose value an agent sees as a mask token and writes back as one (a keyword would refuse the token). A sentence is added to the field description only for a limit that the author declared (`(from 1 character to 2000 characters)`, `(from 0 to 100)`), so that the default adds no words to every text field. The upgrade diff gets the change kind `field_limits`, listed in the plan and never breaking; its message shows the limits the field is held to, the default of the kind included, and which way they move: `limits tighten from max_length 5000 to max_length 2048 (default); values already stored are not re-checked`, `limits loosen from ...`, `limits tighten and loosen from ...` when one limit goes each way (F2: removing a `max_length` is moving to the default, never to "none"; a number with no bound shows `none`). The MCP input schemas of the kernel's virtual commands also state limits that nothing enforces: `field_properties` of `obop-mcp` offers every non-computed, non-governed field of a model as an input of each action, so with a `platform.admin` agent token `module__install` lists 45 inputs and 40 of them carry a `maxLength`, and `user_account__add` lists 12 inputs and 5 of them carry one, while the handlers of the virtual commands run no limit check (reproduced in a real run on 2026-10-29; K44 (b)). OpenAPI is not affected: its request bodies for the virtual commands are generic, and it states limits only for stored models, where `create` and `update` enforce them, except for `connection.result` (100,000), an output-only field of a stored model that the connectors guard drops on create and refuses on update, so nothing enforces its number (K44 (b)). The schemas were left as they are (a lead decision of the review of PR 33). `obop modules check` runs the gate and says the file, the model, the field and the key. The back office form shows a counter (`8 of 10 characters`) from four fifths of the limit, red above it, and a visually hidden `role="status"` region next to it that says `Close to the limit of 10 characters` or `Over the limit of 10 characters` once when the band changes and not at every key; the control is `aria-invalid` while the value is over the limit and was changed (an untouched stored value above a lowered limit is not marked, Decision 6; F14). It refuses a value outside a limit before sending it (`Code must be at most 10 characters; this has 11`), gives a number input its `min` and `max`, and shows the server's own refusal on the field when the browser did not know the limit; it deliberately does not set the HTML attribute `maxlength`, which counts UTF-16 units (an emoji counts 2) and silently cuts a pasted text. The six machine-drafted languages of the back office carry the new strings, the two announcements included (D52). *9. Compatibility and the core version.* Additive for packages, with the one exception (e): a field without limits parses as before, and the stored data does not change. Behaviour that changes for an existing instance, listed so that it is not silent: (a) a write of more than 2,048 characters into a `text` field, or of more than 100,000 into a `longtext` field, that used to succeed is refused, in every package and built-in module, which includes an action effect that copies a longer value into a shorter field (`$self.body`, a `longtext`, into a `text` field fails for a body over 2,048 characters) that the install gate does not see (Decision 4, K44); (b) `/api/meta` gains `max_length` on every written text field, the OpenAPI input schemas and the MCP tool schemas gain the keywords, and declared limits add a sentence to a field description; (c) the new code `limit_violated` and the new upgrade-diff change kind `field_limits`; (d) a record that a connector pushes in, with a text over a limit, is rejected as an item where it used to be stored (Decision 4); (e) a `set_default` migration step that puts a float into a `number` or `money` field (`value = 5.0`), which 0.2.0 accepted, is refused by `obop modules check`, an upload, a preview and an upgrade to a package that carries it, and so is a step whose value is outside the limits of its field; a package that is already installed is exempt and loads, upgrades and rolls back as before (Decision 7). What each rests on under the amendment of MODULES.md 3.1: (b) rests on API fields and field descriptions (OR-35 j); (c) are new values of existing fields (a new `code`, a new change kind): no part of OR-35 names new values of an existing field, which is the record's reading, and the owner may rule otherwise (the wording of D89 Decision 15); (a), (d) and (e) are **not named** by OR-35 h, which covers "the text or the status of a refusal" and not a refusal where there was none, so this record lists them as visible differences and as a point for the owner, does not claim that the amendment covers them, and puts all five in the release notes of the release that ships 0.2.1; the generated texts of OR-35 k (the descriptions of the create, update, bulk_create and delete tools) are not changed. *The version rule* is that of D88 Decision 14 (a lead decision, PROPOSED, not an owner ruling except OR-35 h, j and k): no 0.3.0, each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line. This commit widens it (four new keys) and is the first of those that lands, because D88 and D89 are not built: `CORE_VERSION` becomes **0.2.1**, a package that declares a limit writes `requires_core = "^0.2.1"`, the 23 bundled packages keep `^0.2` (OR-35 h), the tag after the merge would be `v0.2.1-r1` (the lead's call; tags are made by hand), and if D88 or D89 land first this takes the next number: the constant `LIMITS_CORE_PATCH` in `obop-pkg/src/limits_tests.rs`, the `^0.2.1` of the examples and the pins of the bump commit change with it. The test `core_version_requirement_kinds_limits` declares `^0.2.1`, asserts that 0.2.1 and `CORE_VERSION` satisfy it and 0.2.0 does not, with the number written in the test; the bump commit changes the pins that D88 Decision 14 lists (the tests of the core version in `modules_tests.rs`, `health_tests.rs`, `version.rs`, the committed `openapi.json`, the website snapshots under `web/apps/site/data/builtin`, six assertions of `modules.spec.ts` and the examples in DEPLOY.md and spec/cli.md). As D88 Decision 14 says for every key of this kind, the number does not make an older core refuse more politely: on 0.2.0 a package with `max_length` fails at parse time with the parser's message about an unknown field, whatever `requires_core` says. *Not reconciled.* The literal `0.2.0` of OR-32, of the D55 row and Decision, and of the sentence of the Alignment rules are the conforming edit that D88 Decision 14 assigns to whichever of the four commits it names lands first; this commit is not one of the four and does not make that edit (OR-32 and D55 are ruled): the first of the four to land should, or the lead should say that this one does. Two leftovers of the bump were found at the review of PR 33 (F16) and are fixed here: MODULES.md line 21, which D88 Decision 14 lists with them, now says "the version of the running core" and no longer names a number, and the second example of "Which build is running" in DEPLOY.md now reads 0.2.1. *10. Slices.* One package, one slice: the registry, the commands layer, the manifest and gate, the surfaces, the back office and the documents; no storage change, so the three stores (memory, SQLite and PostgreSQL) see the same values, because the check is above them. + +**Threat model.** *Hostile or sloppy input:* a body of 2,000,000 characters, a value that is short in bytes and long in characters (four bytes each) and one that is short in characters and long in bytes are all decided correctly, because the byte shortcut only decides what it can (the test `counting_by_bytes_first_never_changes_the_answer` checks it against a naive count over a generated set); the largest value is bounded by the 2 MiB body limit and the largest `bulk_create` by 1,000 rows, so counting costs at most a pass over the body. *Information:* a refusal names the field and the limit; the limit is in `/api/meta` for a caller who may read the field. It states the length of a text, or the number written, only to a person who typed the value, never to an agent (which holds personal data as a mask token whose length the count would reveal) and never from an action effect (which may copy a field the caller cannot read): Decision 5, F7. It never repeats the text itself and says nothing about stored values. An MCP schema leaves out the keywords for personal data (Decision 8). *A field the caller cannot read:* a caller who writes it learns its limit from the refusal, although `/api/meta` does not show the field to them (F22, accepted and documented, a lead decision). `create` and `update` gate no write by the read group of a field, an unknown field already answers `unknown field`, a wrong kind already prints the options of a select, the precision of a decimal or the target of a ref, and the limit can be found by accepting and refusing alone (a value of the limit is accepted and one more is not), so withholding the number from the sentence would close nothing. Who may write which field is not changed by this record. *Precision:* a decimal bound and a decimal value are compared by `Decimal::compare` (exact, the same scale or not), never through a float; a number bound is an `i64`. + +**Acceptance tests (named).** `obop-registry` `limits_tests`: `the_defaults_are_below_the_caps_and_named_in_one_place`, `the_effective_maximum_is_the_declared_one_or_the_default_of_the_kind`, `counting_by_bytes_first_never_changes_the_answer`, `characters_are_code_points`, `the_default_applies_to_a_text_field_and_a_longtext_has_its_own`, `whole_number_and_decimal_bounds_are_inclusive_and_exact`, `a_breach_says_what_was_wanted_and_what_was_written`, `the_note_for_descriptions_only_speaks_of_declared_limits`, `the_gate_accepts_what_makes_sense_and_a_field_with_no_limits_is_free`, `the_gate_refuses_a_length_that_cannot_hold`, `the_gate_refuses_keys_on_the_wrong_kind`, `the_gate_refuses_bounds_that_do_not_fit_the_kind_or_each_other`, `check_module_runs_the_gate_so_install_and_start_both_refuse`, `the_advice_for_a_min_length_without_a_max_length_names_the_cap`, `a_refusal_without_the_value_says_only_what_the_field_takes`. `obop-commands` `limits_tests`: `the_default_lengths_are_the_documented_constants`, `a_text_is_counted_in_characters_not_bytes` (2,000 Swedish letters and 2,000 emoji pass a limit of 2,000, 2,001 are refused), `the_limit_is_inclusive_at_both_ends_and_a_message_names_the_field_and_the_numbers`, `whitespace_counts_as_characters_and_nothing_is_trimmed`, `a_text_field_without_a_declared_limit_has_the_default`, `a_longtext_field_has_its_own_larger_default_and_a_declared_limit_can_be_lower`, `number_money_and_decimal_hold_their_inclusive_bounds`, `a_decimal_bound_is_exact_to_the_last_digit`, `a_refusal_leaves_the_store_alone`, `update_holds_a_changed_value_to_the_limit`, `a_record_stored_above_a_lowered_limit_stays_editable_in_its_other_fields`, `bulk_create_names_the_row_and_the_limit_without_the_error_code`, `csv_rows_go_through_the_same_check`, `what_an_action_writes_is_held_to_the_limits_of_the_target`, `an_adjustment_cannot_push_a_number_past_its_bound`, `the_refusal_is_a_400_with_its_own_code_and_a_hint`, `the_plain_text_of_a_row_error_drops_only_a_code_of_the_table`, `an_effect_that_meets_a_limit_keeps_the_code_and_the_hint` (a `create` effect, an `adjust` effect and a nested `set_state` action: classified `limit_violated`, with status, hint and rpc data), `an_error_of_an_effect_that_is_not_a_limit_is_wrapped_as_before`, `an_agent_is_not_told_how_long_a_value_it_only_holds_as_a_token_is` (create, update, bulk_create and a number), `an_action_never_states_the_value_it_copied_from_a_field_the_caller_cannot_read` (an ordinary user, an agent and a person of the HR group), `a_decimal_breach_states_no_value_to_an_agent_or_from_an_effect` (a decimal bound: a person is told what was written; an agent, and an action effect whoever runs it, get the sentence without `this is`), `a_state_move_with_an_over_limit_field_is_refused_before_the_action_runs` (for a person and for an agent: the state does not move, no effect row exists, and the agent's refusal states no length), `a_state_move_that_sends_back_an_over_limit_stored_value_is_not_refused_for_it`, `a_decimal_stored_above_a_lowered_bound_stays_editable_whatever_its_spelling`, and, for the public submit, `a_visitor_cannot_send_a_text_longer_than_the_field_allows` and `a_visitor_cannot_send_a_number_or_a_decimal_past_its_bound`. `obop-pkg` `limits_tests`: `every_limit_key_is_read_into_the_field`, `a_bad_limit_is_refused_naming_the_field_and_the_key`, `the_edges_of_what_is_allowed_are_allowed`, `a_default_of_a_migration_step_is_held_to_the_limits_of_its_field`, `core_version_requirement_kinds_limits`, `a_bundled_package_that_uses_a_limit_must_declare_the_core_that_has_them`, `a_changed_limit_is_a_listed_change_and_never_breaking`, `a_float_default_into_a_number_or_money_field_is_refused`, `an_installed_manifest_is_read_as_the_previous_core_read_it` (a float, or a value outside the limits of its field, in a `set_default` step is refused when the manifest is parsed for an install and loads when it is parsed as installed; a string into a number field stays refused in both), and, in `dependency_tests`, `a_float_set_default_is_refused_on_install_and_upgrade_but_not_in_an_installed_package` (an install and an upgrade with a float are refused and change nothing; a stored copy that carries one still loads at start and can be upgraded from), `the_plan_shows_the_default_that_applies_when_a_limit_is_removed_and_which_way_it_goes`. The `connectors` module crate (`src/tests.rs`): `an_inbound_item_over_a_limit_is_rejected_by_itself_and_the_rest_are_kept` (a delivery of three items with one over the limit stores two and rejects the second; a delivery of only over-limit items is refused as a whole and its run is `failed`; the same event id is a replay; a new event is accepted). `obop-mcp`: `a_text_field_says_its_length_limits`, `a_number_says_its_range_and_a_decimal_does_it_as_exact_strings`, `personal_data_gets_no_keyword_because_an_agent_sees_a_token`. `obop` (the binary, over the real router and the real stores): `rest_refuses_a_value_outside_a_limit_with_a_400_that_names_the_field_and_the_numbers`, `rest_update_holds_a_changed_value_to_the_limit_and_spares_an_unchanged_one`, `rest_bulk_create_names_the_row_and_csv_goes_through_the_same_check`, `the_built_in_models_have_the_default_length`, `meta_says_the_limit_of_every_field_that_has_one`, `the_openapi_document_has_the_keywords_on_inputs_and_not_on_records`, `mcp_tool_schemas_carry_the_limits_and_a_call_over_the_limit_is_an_error_result`, `installing_a_package_with_a_limit_that_cannot_hold_is_refused`; the CLI through the real binary (`tests/limits_cli.rs`): `a_package_with_limits_passes_and_the_bundled_ones_still_do`, `a_bad_limit_fails_the_run_and_names_the_file_the_field_and_the_key`; the committed `openapi.json` and its drift test; the back office component tests of `limits.test.tsx` (code points, the singular, no trimming, exact decimals, a draft checked field by field, an unchanged value spared, the counter near the limit, a screen reader told once per band and the control `aria-invalid` only while the changed value is over, no native `maxlength`, the server's sentence in six languages); Playwright `tests/limits.spec.ts` (six tests, the first also asserting the status text and `aria-invalid`: the counter and the refusal before sending, 2,001 characters in a field of 2,000 and 2,000 emoji, a number and a decimal outside their range, the server's refusal when the browser did not know the limit, a stored value over a lowered limit that is not marked invalid until it is changed, in the panel and in a list cell, and an import that names the row and the limit). + +**Consequences.** *Behaviour changes* are the five of Decision 9, to be named in the release notes. *The longest stored value.* On a fresh instance with the demo data, the invented demo shop and blog and all 23 bundled packages installed (94 readable models, 93 records, 477 text values, among them the 2,000-character note that the check itself created and that is left out below), the longest value in a `longtext` field is 2,040 characters (`connector_provider.config_fields`, with 100,000 allowed) and the longest in a `text` field is 140 characters (`connector_provider.legal_name`, with 2,048 allowed). The survey is of seed data and says nothing about the data of a real instance, which is the owner's to measure before the merge (K46 lists the missing tool). That instance has 431 text and longtext fields, 423 of them with an effective `max_length` and the other 8 numbered (`sale_order.number` and the like, which a client never writes). *Not delivered*, each in docs/ISSUES.md: K43 (the numbers of Decision 3 are proposals), K44 (writers that do not go through the checks, effect maps that are checked against limits only when the action runs, inputs of the kernel's virtual commands that state a limit they never meet, and the limit of a field the caller cannot read), K45 (no bounds on dates, no `pattern`, no rule against a blank text, no limits in the module builder), K46 (no scan of stored values above a limit, no operator command that lists them). *Rollback.* Removing a limit from a package is a change of the field (`field_limits`) and needs no step; an image rollback to a core before 0.2.1 makes a package that uses a key fail at parse time (D88 Decision 14 describes the parse failure; D89 Decision 15 describes what that does to the installed list: `load_installed` puts the package in its `failed` list and registers none of its models). *Lead decisions of the review of PR 33 (2026-10-29)* are marked as such above and are not owner rulings: the wording of the reasons for the numbers (Decision 3), keeping `limit_violated` through an effect (Decision 5), withholding the count from agents and from effects (Decision 5), a whole number accepted as a decimal bound (Decision 1), keeping the registry's integer-on-decimal arm (Decision 7), not re-refusing the `set_default` steps of an installed manifest (Decision 7), no install-time check of effect maps (Decision 4), accepting the disclosure of the limit of a field the caller cannot read (Threat model), leaving the ingress as it is (Decision 4), and leaving the schemas of the virtual commands as they are (Decision 8, K44 (b)). The owner's two points (the numbers; the version effect of Decision 9) are open and unchanged. + +**Why.** Every client-side limit is advice: the platform's client can be forged, an agent and a script do not run the form, and `required` already showed that "present" and "sensible" are two different things. One definition in the registry means the gate, the engine, the documents the surfaces generate and the form cannot disagree about what a limit is or how it counts. A default bound closes the 2,000,000-character body without asking every package author to think about it, and a per-field override up to a cap keeps the exceptions inside a number the owner has seen. **Rejected.** Bytes (a limit of 2,000 would be 1,000 Swedish letters and 500 emoji, and the client would have to guess the encoding); trimming or normalising before counting (a second spelling of the same text, and a client that counts differently would pass what the server refuses); counting what a person sees as a character (grapheme clusters need the Unicode tables, differ between versions and between the browser and the server, and a family emoji of 7 code points would be 1); the HTML `maxlength` attribute (UTF-16 units and a silent cut of a paste); a default only for new packages (the open door for every existing field would stay); a per-instance setting for the default (every instance would then behave differently and a package could not know the line); making `required` mean non-empty (it would refuse writes that existing packages make today); a limit that re-validates stored rows at read time (a lowered limit would hide or break records nobody changed); keywords on the record schemas of OpenAPI (a stored value may be above a lowered limit); one cap for both kinds (a one-line field of 1,000,000 characters is not a one-line field). + ## 3. Contradiction rulings | # | Topic | Ruling | Authority | Status | @@ -1306,7 +1326,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point | Family | Meaning in this register | Defined in | |---|---|---| -| D1-D89 | Decision records | Section 2 | +| D1-D90 | Decision records | Section 2 | | OR-1 to OR-35 | Owner rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34) and 2026-10-28 and 2026-10-29 (OR-35) | "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" above (OR-35 is the last row of the second table) | | Q1-Q61 | Owner questions | Section 8 | | F1-F6 | FIXED owner constraints | Section 1 | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 9b17e0de..bcbf56b2 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -641,7 +641,7 @@ See "The published image": build, then `obop platform upgrade-instances --image The build attaches SLSA provenance (`mode=max`) and an SBOM as attestations, and the workflow runs `obop --version` from the pushed image and fails unless it reports the commit that was built. It authenticates with the repository's `GITHUB_TOKEN` (`packages: write`); no other credential exists. Actions are pinned by commit SHA. arm64 is not built: under emulation the Rust build takes hours; add a matrix job on a native arm64 runner and merge the manifests when it is needed. The workflow has never been run from this repository's history (it needs GitHub); the first run is the test, so watch it. -**Which build is running.** `obop --version` prints `obop 0.2.0 (abcdef012345)`: the core version, then the first 12 digits of the commit the binary was built from, so a check that pins a build can tell two builds of one version apart. The commit is read by `crates/obop/build.rs`: the environment variable `OBOP_BUILD_COMMIT` (12 to 64 hexadecimal digits; any other value is ignored, with a warning in the build output) if it is set, else `git rev-parse HEAD` when git is installed and the sources are the top of their own checkout (a plain clone or a linked worktree, on a loose or a packed branch), else `unknown`, printed as `obop 0.2.0 (unknown)`. In a checkout the build runs again when a commit, a checkout, a reset, a pull or `git pack-refs` changes what `HEAD` names; a tree whose git was missing at the first build keeps `unknown` until `crates/obop/build.rs` is touched. The image has no `.git`, so the Dockerfile takes the build argument of the same name and `image.yml` passes `${{ github.sha }}`; build it yourself with `docker build --build-arg OBOP_BUILD_COMMIT=$(git rev-parse HEAD) -t obop:local .`. The argument is declared after the Rust dependencies are cooked, so a new commit does not rebuild them. There is no `-dirty` mark: Cargo runs a build script again only when a file it names changes, so a mark for uncommitted edits would go stale at the first one. A commit hash is not secret; the line is printed by the program and the log of the pushed image check only. +**Which build is running.** `obop --version` prints `obop 0.2.1 (abcdef012345)`: the core version, then the first 12 digits of the commit the binary was built from, so a check that pins a build can tell two builds of one version apart. The commit is read by `crates/obop/build.rs`: the environment variable `OBOP_BUILD_COMMIT` (12 to 64 hexadecimal digits; any other value is ignored, with a warning in the build output) if it is set, else `git rev-parse HEAD` when git is installed and the sources are the top of their own checkout (a plain clone or a linked worktree, on a loose or a packed branch), else `unknown`, printed as `obop 0.2.1 (unknown)`. In a checkout the build runs again when a commit, a checkout, a reset, a pull or `git pack-refs` changes what `HEAD` names; a tree whose git was missing at the first build keeps `unknown` until `crates/obop/build.rs` is touched. The image has no `.git`, so the Dockerfile takes the build argument of the same name and `image.yml` passes `${{ github.sha }}`; build it yourself with `docker build --build-arg OBOP_BUILD_COMMIT=$(git rev-parse HEAD) -t obop:local .`. The argument is declared after the Rust dependencies are cooked, so a new commit does not rebuild them. There is no `-dirty` mark: Cargo runs a build script again only when a file it names changes, so a mark for uncommitted edits would go stale at the first one. A commit hash is not secret; the line is printed by the program and the log of the pushed image check only. Build the image locally with `docker build -t obop:local .`. `docker build --target web .` builds only the website and back office (it needs `Cargo.toml` in the build context for the core version, which the Dockerfile copies). diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index d9a0f5e5..ada7ba32 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -113,7 +113,7 @@ flowchart TB | **Data class** | A lower snake_case tag on columns that sets default readers and handling: `public_directory`, `internal`, `confidential_hr`, `special_category`, `financial` and `secret` (Proposed). `confidential_hr`, `special_category` and `financial` are the sensitive classes, kept in separate tables or columns behind `obop_cls_*` database roles. A data class is not a permission group or a retention class, and product cost and margin have no class (they sit behind the field permission `product.cost_viewer`). | [CONVENTIONS 5](CONVENTIONS.md#5-data-class-registry); [spec/security-privacy 4.2](spec/security-privacy.md#42-authorization) | - | | **DATEV** | The German accountants' organisation and software vendor (over 40,000 members, per DATEV) that publishes the SKR03 and SKR04 charts. The DATEV export is one of the German audit files obop generates, open and free; DATEV Connect online is partner-gated, and written DATEV confirmation of SKR03/04 is a D4 precondition. | [LOCALIZATION 3.3.1](LOCALIZATION.md#331-germany-l1-module-l10n_de-pack-l10n-de-w1); [LOCALIZATION 3.6](LOCALIZATION.md#36-licensing-blockers); [DECISIONS D4](DECISIONS.md#d4-first-market-icp-and-mvp-shape) | Odoo ships SKR03/04 and keeps the returns and exports in paid modules such as `l10n_de_reports` | | **DCO** | Developer Certificate of Origin: every commit carries a `Signed-off-by` line, and a required CI check rejects commits without it (D82; the check is not built yet). It replaces the CLA (OR-1): there is no copyright assignment, contributors keep their copyright and license it under Apache-2.0 (inbound equals outbound), source files carry SPDX identifiers and NOTICE holds the attribution. The owner signs off every commit (`git commit -s`), and agents are recorded by a `Co-Authored-By` trailer and give no sign-off (RATIFIED, OR-18); counsel is still asked to confirm that this satisfies the DCO (a counsel item, Q16). | [DECISIONS D82](DECISIONS.md#d82-contribution-model-dco-no-cla-apache-20-in-and-out); [ENGINEERING 8](ENGINEERING.md#8-community-and-contribution-process) | Odoo asks for a licence-grant CLA [R03 s3.1] | -| **Decision record (D-ID)** | A numbered entry in [DECISIONS.md](DECISIONS.md) with a status: FIXED, RATIFIED, BINDING, PROPOSED, DEFERRED or SUPERSEDED. F1 to F6 are the fixed constraints, I-1 to I-4 the interpretations, OR-1 to OR-21 the owner rulings of 2026-10-01 (OR-22 to OR-34 those of 2026-10-27, OR-35 those of 2026-10-28 and 2026-10-29) and Q1 to Q61 the owner questions, some of them closed by those rulings and two (Q57, Q58) by answers of the platform team; a change needs a new ADR with the next free D-ID (D1 to D89 exist today). | [DECISIONS 0](DECISIONS.md#0-front-matter-and-how-to-read); [DECISIONS 8](DECISIONS.md#8-open-questions-for-the-owner); [DECISIONS, Owner rulings](DECISIONS.md#owner-rulings-2026-10-01) | - | +| **Decision record (D-ID)** | A numbered entry in [DECISIONS.md](DECISIONS.md) with a status: FIXED, RATIFIED, BINDING, PROPOSED, DEFERRED or SUPERSEDED. F1 to F6 are the fixed constraints, I-1 to I-4 the interpretations, OR-1 to OR-21 the owner rulings of 2026-10-01 (OR-22 to OR-34 those of 2026-10-27, OR-35 those of 2026-10-28 and 2026-10-29) and Q1 to Q61 the owner questions, some of them closed by those rulings and two (Q57, Q58) by answers of the platform team; a change needs a new ADR with the next free D-ID (D1 to D90 exist today). | [DECISIONS 0](DECISIONS.md#0-front-matter-and-how-to-read); [DECISIONS 8](DECISIONS.md#8-open-questions-for-the-owner); [DECISIONS, Owner rulings](DECISIONS.md#owner-rulings-2026-10-01) | - | | **Default-deny** | Access with no matching permission row is refused at model, field and action level. Every permission row declares a scope (`all`, `own`, `company` or `domain`), an implicit TRUE is a lint error, and a by-id read that fails returns 404, not 403 (D10). | [spec/security-privacy 4.2](spec/security-privacy.md#42-authorization); [ARCHITECTURE 7.2](ARCHITECTURE.md#72-the-algebra-d10) | Record rules were default-allow before 19.4 [R02 s3] | | **`delete_blocked`** | Proposed (D89), not built: the error code (status 409) of a refused delete. The body names only the referring records that the viewer may list, and gives one neutral text for every other cause, so that a refusal does not reveal a record the caller cannot see. One bit remains for a `refuse` rule: that something blocks the delete (accepted by the owner, OR-35 a). | [DECISIONS D89](DECISIONS.md#d89-delete-rules-on-references-refuse-cascade-set_null-and-a-refusal-that-does-not-reveal-private-rows) | - | | **Deprecation window** | How long a deprecated public contract (REST and MCP, the CLI command catalogue, the pack schema, `obop.toml`, the UI SDK) keeps working after the deprecation is announced. Before 1.0 it is 12 months (RATIFIED, OR-12); after 1.0 it is Proposed to stay 12 months (Q28). The 24-month floor of the earlier proposal is withdrawn. A deprecated CLI command keeps working and adds a `warnings[]` entry, and `--strict` turns it into a failure. | [DECISIONS D85](DECISIONS.md#d85-support-promise-and-release-train); [spec/cli 6](spec/cli.md#6-parity-testing-and-release) | Odoo schedules its legacy RPC services for removal in Online 21.1 and Odoo 22 (fall 2028) [R00 s3.5] | diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a73949e2..a4aec89e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -178,7 +178,7 @@ Rows of this section only; another section of the register is kept apart so the ## K. Storage foundations (step 1: D72 to D74) -The rows K38 and K39 are not storage foundations: they were found while writing the decision records D88 (model rules) and D89 (delete rules). The row K42 is not a storage foundation either: it is a finding about the image workflow, seen in CI on 2026-10-29. +The rows K38 and K39 are not storage foundations: they were found while writing the decision records D88 (model rules) and D89 (delete rules). The row K42 is not a storage foundation either: it is a finding about the image workflow, seen in CI on 2026-10-29. The rows K43 to K46 are not storage foundations either: they were found while building the field limits of D90 (kinds-limits). | # | Item | Status | |---|---|---| @@ -224,6 +224,10 @@ The rows K38 and K39 are not storage foundations: they were found while writing | K40 | Found while closing PR 29, recorded and not fixed: the header comment of `obop-storage/src/guard.rs` (lines 16 and 17) says that outside a request scope (the connector worker, provisioning threads, the CLI) the guard does nothing but delegate. `obop import` (`import_cmd.rs`), `obop call` (`main.rs`) and the stdio MCP server (`obop-mcp`) run inside `errors::requested`, which opens the scope, so the header is stale for these three; the other CLI commands (for example `obop admin`, `obop sequences` and `obop modules`) call `open_app` and then the dispatcher, the backend or `Auth` directly, still run outside a scope, and the header should say which ones (read in the code, not run) | open | | K41 | The `obop_uq_*` indexes of the unique rules (D57) are created at start and at install and are never dropped: not when a module is uninstalled, not when a rule is removed (the `Backend` trait has no drop, and `unregister_module` leaves the data and the indexes). An orphaned index keeps enforcing, and a violation of it comes out as raw storage text, because `explain_db_error` maps only the registered models. On PostgreSQL every statement that reads, updates or deletes on `obop_record` holds one relation lock per index of the table; an INSERT holds none of them (measured on 17.11 in a scratch database with the real index DDL: a single-row INSERT held 2 relation locks with 53 and with 1,293 indexes; a keyed SELECT and a SELECT, UPDATE or DELETE by primary key held the number of indexes plus about 3). The server's lock table has `max_locks_per_transaction` times `max_connections` slots, 6,400 with the defaults of 64 and 100. With 1,290 rule indexes, 6 concurrent transactions each holding one keyed SELECT were all fine, 3 of 10 failed with SQLSTATE 53200 ("out of shared memory") and 12 of 14 failed; one connection alone was fine at 6,450 indexes. The advisory stripe locks of DEPLOY "Batches that name the same records" are a separate count and do not include these. Today this is a hazard of test environments and of the module builder, not of production: the bundled catalog declares well under 40 unique rules. It is real in the shared `postgres` database of the `obop-pg` test container, which holds 1,290 leftover `obop_uq_w_...` indexes from the old-style tests of `obop-commands` (`engine_tests.rs` `rig_for` and the like run against the URL's own database with a random model name and never drop what they create): 72 of 224 `obop-commands` lib tests fail there with 53200, on origin/main as well (found during the review of PR 28, whose PostgreSQL results were taken in a scratch database). Fix options: those tests use `TestDb` (a scratch database that is dropped afterwards), and an index drop path (D78 `index prune`, PROPOSED) | open | | K42 | The image workflow (`.github/workflows/image.yml`) runs only on a push to the branch `main` or `deploy` (and by hand, `workflow_dispatch`, on one of those two branches); a push of a tag builds nothing. It tags the image `:` and `:`, and has `concurrency: group image-${{ github.ref }}` with `cancel-in-progress: true`, so a newer push to the same branch cancels a build that is still running. Observed 2026-10-29: the squash merge 5f8c3a9 of PR 28 was tagged `v0.2.0-r32`; the docs merge 01ec24f was pushed to `main` about three and a half minutes later (the two runs were created at 09:09:32Z and 09:13:02Z), before the image build of 5f8c3a9 had finished, and attempt 1 of the run for 5f8c3a9 (run 40847432918) was cancelled in its "Build and push" step (the step ended at 09:13:20Z, the job at 09:13:38Z), so that attempt published no image `:5f8c3a9...`. The run was then re-run by hand: attempt 2 started at 09:18:43Z and succeeded, including the step that starts the pushed image and checks the commit it reports, and published `:5f8c3a9...`. Because attempt 2 finished (09:22:03Z) after the run of 01ec24f (09:17:57Z), `:main` pointed at the 5f8c3a9 image, behind the head of `main` (01ec24f), when read from ghcr.io on 2026-10-29 (`:main` and `:5f8c3a95...` had the same digest, sha256:d62022b8...; `:01ec24fd...` was sha256:e012e18c...); it stays there until the next push to `main`. Consequences: a tagged code commit that is followed by any push to `main` within one build time (about 4 minutes) has no image of its own until its run is re-run; re-running the cancelled run builds the right commit, but it also moves the `:main` tag back to that older commit until the next push to `main`, as happened here. Options, not decided: build on tag pushes too; set `cancel-in-progress` to false for `main`; add the commit to the group name | open | +| K43 | D90 (field limits): the four numbers of the default length and the cap (2,048 and 100,000 as the default for `text` and `longtext`, 10,000 and 1,000,000 as the largest `max_length` a field may declare) are proposals: round numbers with a margin over the longest values in the seed data (140 and 2,040 characters; 2,048 is also the conservative URL ceiling of sitemaps.org and old browsers, modern browsers accept more, and it is this project's own limit for an OAuth redirect URI; D90 Decision 3), not measured on a real instance; they are constants in `crates/kernel/obop-registry/src/limits.rs` and the product owner rules on them before the merge. The default is a behaviour change for every existing package (a write of more than 2,048 characters into a `text` field was accepted and is refused from 0.2.1), which MODULES.md 3.1 as amended by OR-35 h does not name (it covers the text or status of an existing refusal); D90 Decision 9 lists it as a visible difference and asks the owner. Lowering a number after the first release that ships the keys does more than fail writes: `check_installed_module` and `Manifest::field_def` refuse an installed package that declares a `max_length` above the new cap (or a `min_length` above the new default with no `max_length`) at every start, so the package lands in the `failed` list and its models do not register, the hazard of D89 Decision 15 for a rollback (D90 Reversibility; reproduced at the review of PR 33 by lowering the text cap to 5,000 under a package at `max_length = 8000`). The field gate is not relaxed for an installed package; only the checks of a `set_default` migration step are (D90 Decision 7). The numbers are cheapest to change before that release. The two owner points of D90 (the four numbers; whether a patch release may add a refusal where there was none) are not entries of the question register in DECISIONS.md section 8 and are not in its counts | open (owner) | +| K44 | D90, recorded and not fixed: (a) a record that a Rust handler writes straight to the store (the partial updates that the connectors runtime makes to a connection: `last_sync_at` and `last_error` after a run, cut to 200 characters at `obop-connectors` `runtime.rs:394-402`, `state` at `runtime.rs:428` and `handlers.rs:248`, `oauth_tokens` at `runtime.rs:1167`, and `credentials`, `credentials_hint` and `oauth_tokens` at a revocation, `runtime.rs:1296`; the consents and the sync runs of the connectors module; the control plane, the forum, `obop-auth`'s user administration, the module installer's own records) does not go through `check_fields`, so no limit applies to what those handlers write, and nothing has checked whether any of them writes more than the default into a field that a client could later update; the "unchanged" rule of D90 Decision 6 keeps an update of another field working. Not listed one by one: reading every in-process writer was out of scope. A connection itself is not in this list: it is created and updated through the generic `create` and `update`, so its limits apply (`last_error` of 2,049 characters is refused on `/api/connection/create` with 400 `limit_violated`, a real run). A record that a connector receives from a sender is not in this list either: `obop-connectors` `ingress.rs::accept_one` writes it through the Dispatcher (`{model}.create` or `.update`), so the limits, the default included, apply and an item over a limit is rejected by itself; the other items of the delivery are kept, the run is `partial` (`failed` when every item was rejected) and keeps the first message only, there is no per-item dead letter, the answer is 202 with counts (422 when all were rejected), and the sender's retry of the same event id is a replay. That is how every rejection of an inbound item behaves; D90 adds a cause and does not change the ingress (test `an_inbound_item_over_a_limit_is_rejected_by_itself_and_the_rest_are_kept`). (b) the MCP input schemas of the kernel's virtual commands state limits that nothing enforces. `field_properties` of `obop-mcp` offers every non-computed, non-governed field of a model as an input of each action, and the registry cannot tell an output-only field from a written one (they are `FieldKind::Text`), so with a `platform.admin` agent token `module__install` lists 45 inputs and 40 of them carry a `maxLength`, and `user_account__add` lists 12 inputs and 5 of them carry one (a real run on a scratch database, 2026-10-29; the reviewers of PR 33 saw the same). The handlers of the virtual commands read the inputs they use and run no limit check, so no value is refused there for that number and the stated number is wrong for a reader of the schema. `/api/meta` likewise states `max_length` (the default, 2,048) on the output-only text fields of the kernel's virtual models (for example `module.contents`, which holds a long list, and `security_event.detail`). OpenAPI is not affected: the request bodies of the virtual commands are generic (`/api/module/install` has one property), and it states limits only for stored models, where `create` and `update` enforce them, except for one output-only field of a stored model: the committed `openapi.json` gives `connection` `last_error` 2,048 and a `last_error` of 2,049 characters is refused on `/api/connection/create` with 400 `limit_violated` (a real run), but it also gives `connection` `result` 100,000 on `/api/connection/create` and `/api/connection/update`, and nothing enforces that number: the connectors guard `normalise` (`crates/modules/connectors/src/guards.rs`) drops `result` on create (`:384`), so a 100,001-character `result` is accepted and not stored (a real run), and refuses it on update (`:377`). The schemas were not changed (a lead decision of the review of PR 33). (c) the install gate checks the kind of an effect's values and not their limits (`shape_ok` in `obop-registry` `rules.rs`), so an effect that writes a constant outside the limits of its target, or copies a longer field into a shorter one (`$self.body`, a `longtext`, into a `text` field), passes `obop modules check`, also with `--catalog`, and is refused when the action runs, with `limit_violated` and nothing written; a static check of constants would be cheap, a static check of copies cannot refuse existing `^0.2` packages (a lead decision of the review of PR 33: not in this record). (d) a caller who writes a field that its read group hides from them learns its limit from the refusal, as `unknown field` and the wrong-kind text already tell its existence and kind; `create` and `update` gate no write by the read group of a field (D90 Threat model, a lead decision of the review of PR 33). | open | +| K45 | D90, not delivered: bounds on `date` and `datetime` (a date not in the past, a booking window); a `pattern` (a regular expression) on a text field; a rule that refuses a blank text (`min_length = 1` refuses `""` but accepts `" "`, because a length counts what was sent and nothing is trimmed; the platform's ask 11.3 notes that a post of spaces is stored today and asks for `min_length` of 1 or `required` meaning non-empty: `min_length = 1` answers the first half only, and `required` is unchanged); a limit on the number of items of a `select` or on a file's size beyond `max_bytes`; limits in the Customize module builder (`web/apps/backoffice/src/ModuleBuilder.tsx` writes no `max_length`, `min_length`, `min` or `max`, so a field made there has the default length and no range, and a `longtext` field is the only way to hold more); a per-instance setting for the default (rejected in D90 Why); the forms that the server renders itself (the public site, the forum and status pages, the OAuth pages) were not changed to carry `maxlength`; the forum and the status pages are not registry models and have limits of their own that D90 does not touch, and a public submit that breaks a limit is rejected as a whole with the text of a direct write and no code, but the two places that call it, the public site's contact form (which first checks the name at 120 and the message at 5,000 characters itself) and the shop checkout, show the visitor only "Something went wrong. Please try again." and not the field or the limit (`err.generic`, `obop-site` `content.rs` and `checkout.rs`); those pages were not changed | open | +| K46 | D90, not delivered: no tool lists the stored values that are above a limit that was set or lowered later (an operator who upgrades to 0.2.1, or a package author who lowers a `max_length`, cannot see which records now hold a value above it; the records stay readable and editable in their other fields by D90 Decision 6, and the value itself can only be replaced by a shorter one); the upgrade plan lists the change of a limit (`field_limits`) without counting the rows it would affect; no longest-value report exists for a real database, so the survey in D90 Consequences covers only the seed data of a fresh instance (longest `text` value 140 characters, longest `longtext` value 2,040). An `obop storage check`-style command that reports the longest value and the count above the limit per field would answer both | open | ## L. Mobile sign-in (step 3 of the first mobile slice: the D64 amendment) diff --git a/docs/MODULES.md b/docs/MODULES.md index d505c0e0..b5342279 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ X1 packages cannot run anything, so an administrator can install one from an upl ### What ships -Built-in modules (Rust crates, not removable): `base` (model `party`), `product` (`product`), `crm`, `sales` (`sale_order`), `invoicing` (`invoice`), `connectors` (`connection`, `consent`, the provider catalogue and the other records of docs/CONNECTORS.md), `mileage` (`mileage_trip`, `mileage_point`: trips recorded by the Mileage app and reviewed in the back office, docs/spec/mobile.md 8.6). Bundled packages in `packages/*.toml` (installed from the Customize app or `obop modules install-from ID`). Built-in modules version with the core (`0.2.0`, D55); every bundled package declares `license = "AGPL-3.0-or-later"`, `requires_core = "^0.2"`, its own version and its dependencies with ranges: +Built-in modules (Rust crates, not removable): `base` (model `party`), `product` (`product`), `crm`, `sales` (`sale_order`), `invoicing` (`invoice`), `connectors` (`connection`, `consent`, the provider catalogue and the other records of docs/CONNECTORS.md), `mileage` (`mileage_trip`, `mileage_point`: trips recorded by the Mileage app and reviewed in the back office, docs/spec/mobile.md 8.6). Bundled packages in `packages/*.toml` (installed from the Customize app or `obop modules install-from ID`). Built-in modules version with the core (the version of the running core, D55); every bundled package declares `license = "AGPL-3.0-or-later"`, `requires_core = "^0.2"`, its own version and its dependencies with ranges: | Package | Needs | What it holds | |---|---|---| @@ -88,7 +88,7 @@ A model needs a description and two groups: people holding `read_group` can read Each model automatically gets seven commands, with the same names on REST, MCP and the CLI: `.get`, `.list`, `.search`, `.create`, `.bulk_create`, `.update` and `.delete`. Records get an `id` assigned by the server. `list` and `search` take the optional `filter`, `order`, `limit` and `offset` of section 4.1; `delete` removes a record that no other record refers to and, being destructive, is only *proposed* by an agent. A field that is the target of an action is governed (section 6.1): `create` and `update` cannot set it freely. -`bulk_create` (D70) makes up to 1,000 records in one call, from `rows` (a list of objects) or `csv` (the text of a CSV file whose header names the fields). Each row gets exactly the checks of `create` (kinds, required fields, references the caller may see, governed fields starting at their first option, unique values also among the rows of the call, sequences, `own` defaulting to the caller, the row-level rule that a creator may not make what they could not then see; computed and numbered fields cannot be set), the write group of the model is the permission, and an agent needs approval exactly where `create` would. The call is all or nothing unless `mode` is `partial`; `dry_run` checks without writing; `idempotency_key` makes a retried call return the first answer. A CSV file is UTF-8 (a byte order mark is dropped), separated by commas or semicolons, at most 1 MiB; references are the **id** of the record they point to (there is no way to point at a record of the same file, and no remapping of ids); `number` and `money` cells are whole numbers (money in minor units such as cents: a decimal point is an error); a `decimal` cell is written with a point and no thousands separator (`12.50`) and, when it has more fractional digits than the field's `scale`, is rounded with the field's `round` mode, or is an error where the field has none; yes/no fields take `true`, `false`, `yes`, `no`, `1` or `0`; dates are `YYYY-MM-DD`; a `datetime` cell is an RFC 3339 instant with an offset or `Z` (`2026-03-29T03:30:00+02:00`); an empty cell leaves the field out, while a cell of only spaces is an error for a `decimal` or `datetime`; **image and file fields cannot be imported** (upload and attach them one record at a time). A module needs to do nothing to get it, and a model that its caller cannot `create` does not offer it. +`bulk_create` (D70) makes up to 1,000 records in one call, from `rows` (a list of objects) or `csv` (the text of a CSV file whose header names the fields). Each row gets exactly the checks of `create` (kinds, field limits (D90: a row over a limit is named by its number and the limit, without the `limit_violated` code; a call in the default `all_or_nothing` mode answers a 400 that names the first five rows and creates nothing, while `partial` mode and `dry_run` answer a success whose `errors` list names every refused row), required fields, references the caller may see, governed fields starting at their first option, unique values also among the rows of the call, sequences, `own` defaulting to the caller, the row-level rule that a creator may not make what they could not then see; computed and numbered fields cannot be set), the write group of the model is the permission, and an agent needs approval exactly where `create` would. The call is all or nothing unless `mode` is `partial`; `dry_run` checks without writing; `idempotency_key` makes a retried call return the first answer. A CSV file is UTF-8 (a byte order mark is dropped), separated by commas or semicolons, at most 1 MiB; references are the **id** of the record they point to (there is no way to point at a record of the same file, and no remapping of ids); `number` and `money` cells are whole numbers (money in minor units such as cents: a decimal point is an error); a `decimal` cell is written with a point and no thousands separator (`12.50`) and, when it has more fractional digits than the field's `scale`, is rounded with the field's `round` mode, or is an error where the field has none; yes/no fields take `true`, `false`, `yes`, `no`, `1` or `0`; dates are `YYYY-MM-DD`; a `datetime` cell is an RFC 3339 instant with an offset or `Z` (`2026-03-29T03:30:00+02:00`); an empty cell leaves the field out, while a cell of only spaces is an error for a `decimal` or `datetime`; **image and file fields cannot be imported** (upload and attach them one record at a time). A module needs to do nothing to get it, and a model that its caller cannot `create` does not offer it. ### 2.3 The main model: kinds, refs, a field-level group, personal data @@ -267,7 +267,7 @@ The table maps a module id to the **range of versions of it that this module sup The core follows semantic versioning. While it is below 1.0, a **minor** bump (`0.2` to `0.3`) may break manifests, commands or the API; a **patch** bump does not remove or rename a manifest key, a command or an API field, but it may add optional manifest keys, values of an existing key, functions of the expression language and routes, and may change the text or the status of a refusal where a decision record lists the change as a visible difference and the release notes list it (accepted by the owner, OR-35 h); it may also add API fields and change field descriptions in the MCP and OpenAPI documents (accepted by the owner, OR-35 j); it may also change the generated texts that tell agents what each operation does (the descriptions of the create, update and bulk_create tools, of every delete and of security_event; accepted by the owner, OR-35 k), where a decision record lists the change as a visible difference and the release notes list it (a condition the record applies by analogy to OR-35 h; the owner was not shown it for part (k)); from 1.0 only a major bump may break them. A package therefore writes `requires_core = "^0.2"` (runs on 0.2.x), widens it to `">=0.2, <0.4"` after testing on 0.3, and bumps its own version by the same rule. A package that depends on another package picks a range it has tested, for example `projects = ">=0.3, <1"`, so a breaking release of `projects` (a new major version, or a new minor while it is below 1.0) is caught by the resolver before anything is installed. -Proposed (the version mechanism, a lead decision of 2026-10-28, not an owner ruling; D88 Decision 14, D89 Decision 15; not ratified): a package that uses a key, value or function added by a patch release writes `requires_core = "^0.2.N"`, N being the patch release that added it; `^0.2` still means 0.2.x for a package that uses none, and the 23 bundled packages keep `^0.2` (OR-35 h). The plan to give model rules and delete rules patch numbers of the 0.2 line, and which commit takes which number, rests on the sentence about a patch bump above. +Proposed (the version mechanism, a lead decision of 2026-10-28, not an owner ruling; D88 Decision 14, D89 Decision 15; not ratified): a package that uses a key, value or function added by a patch release writes `requires_core = "^0.2.N"`, N being the patch release that added it; `^0.2` still means 0.2.x for a package that uses none, and the 23 bundled packages keep `^0.2` (OR-35 h). The plan to give model rules, delete rules and field limits (D90) patch numbers of the 0.2 line, and which commit takes which number, rests on the sentence about a patch bump above. The first of them to land is 0.2.1: the field limits `max_length`, `min_length`, `min` and `max` (3.3, D90) need `requires_core = "^0.2.1"` if D90 lands first, and the next number if D88 or D89 lands before it. A write of more than the default length (2,048 characters for `text`, 100,000 for `longtext`) is refused from that release on, in every package; it is a refusal where there was none, which the amendment above does not name, so D90 lists it as a visible difference for the owner. ### 3.2 `[[model]]` (any number) @@ -301,6 +301,8 @@ Proposed (the version mechanism, a lead decision of 2026-10-28, not an owner rul | `kind` | string | `text` | `text`, `longtext`, `number`, `money`, `date`, `datetime`, `decimal`, `bool`, `select`, `ref`, `sequence`, `image`, `file`. | | `precision`, `scale` | integer | none | Only for `decimal`, and then both are required: `precision` is the number of digits in all (1 to 38), `scale` the digits after the point (0 to 12, not above the precision). `precision = 12, scale = 2` holds `-9999999999.99` to `9999999999.99`. | | `round` | string | none | Only for `decimal`: `half_even`, `half_up` or `down`. On a stored field it is how an import (`bulk_create`, `obop import`) cuts input that has too many fractional digits; without it such input is an error everywhere. `round` cuts fractional digits only: input is still limited to 80 characters and 38 significant digits in all, so a number with more digits than that is refused even on import, whatever the mode. On a computed field it is the rounding of every decimal division and of the result (default `half_even`). | +| `max_length`, `min_length` | integer | `max_length`: 2,048 for `text`, 100,000 for `longtext`; no `min_length` | Only for `text` and `longtext` (D90; Proposed; needs core 0.2.1, so write `requires_core = "^0.2.1"`). The most and the fewest characters a written value has, counted in Unicode code points and not bytes (`å`, `ä`, `ö` and an emoji count 1 each; nothing is trimmed, so `min_length = 1` refuses `""` and accepts `" "`). A field that declares no `max_length` has the default of its kind, and a declared one may not exceed 10,000 (`text`) or 1,000,000 (`longtext`); both numbers are the owner's to confirm. A value outside the limits is refused with HTTP 400, code `limit_violated`, naming the field and the limit, on `create` and `update` (REST, MCP and the CLI) and on an action's effects (the code is kept through the effect's context). The other writers refuse in their own way: a row of `bulk_create` and of an import is named by its number and carries no code (a 400 only in `all_or_nothing` mode); a record that a connector pushes in is rejected as that one item and the others of the delivery are kept; a public form (`[model.public]`) is rejected as a whole batch, with the text of a direct write and no code, and the public site's contact form and shop checkout show the visitor only "Something went wrong. Please try again.". The sentence says how long or how large the value was only to a person who typed it directly: to an agent, and from an action's effect, it says only what the field takes (`must be at most 100 characters`). A value that is already stored is not checked again on an `update` that leaves it as it is, so lowering a limit does not lock existing records. Neither key makes a field `required`. Not allowed on a computed or numbered field. | +| `min`, `max` | integer, or a string for `decimal` | none | Only for `number`, `money` (whole numbers, money in minor units) and `decimal` (D90; Proposed; core 0.2.1). The smallest and largest value, both inclusive and exact. For `number` and `money` write a whole number (a TOML integer). For `decimal` write a string, `min = "0.10"`, or a whole number, with at most as many fractional digits as the field's `scale` (fewer are padded: `min = 0` on a `decimal(8, 2)` is `0.00`) and within its `precision`; a TOML float, more fractional digits than the scale and a value beyond the precision are refused. `min` above `max` is refused. Refused values get `limit_violated` as above. | | `max_bytes` | integer | 5 MiB (`image`), 10 MiB (`file`) | Only for `image` and `file`: the largest upload accepted for the field. | | `options` | array of strings | none | Only for `select` (then required, non-empty, no blanks, no duplicates). | | `to` | string | none | Only for `ref` (then required). Model name. | @@ -423,6 +425,10 @@ Parse and gate errors (from `obop modules install`, the Customize upload or `mod | `M.F: a ref needs `to`, the target model` | Add `to`. | | `M.F: `to` "X" is not a model name` | Target must be an identifier. | | `M.F: `to` names "X", which this package does not declare` | A ref to a model that starts with your own id must be one of your models. | +| Proposed (D90): `M.F: `max_length` must be at least 1` / `` `min_length` must be at least 1 ... `` / `` `max_length` N is above the largest a text field may declare, 10000 `` | A length limit of 0 limits nothing or allows nothing, and no field may declare more than the cap of its kind (10,000 for `text`, 1,000,000 for `longtext`). Use `longtext` for a longer text. | +| Proposed (D90): `M.F: `min_length` N is above `max_length` M, so no value fits` / `` `min_length` N is above the default maximum ... declare a `max_length` of at least N `` / `` `min_length` N is above 10000, the most a text field can hold; use a longtext field for more `` / `` `min` X is above `max` Y, so no value fits `` | Fix the pair so that at least one value fits, or use a `longtext` field when more room is needed. | +| Proposed (D90): `M.F: `max_length` and `min_length` are only valid for kind "text" or "longtext"` / `` `min` and `max` are only valid for kind "number", "money" or "decimal" `` / `` a computed or numbered field is never written, so it takes no limits `` | Put the key on a field of the right kind, or remove it. | +| Proposed (D90): `M.F: `min` must be a whole number (a TOML integer without quotes) for kind "number"` / `` `max` of a decimal field must be written as a string such as "0.10": a TOML float is not exact `` / `` `max` "1.234" is not a decimal that fits decimal(10, 2) with at most 2 digits after the point `` | Write a bound of a `number` or `money` as a whole number, and a bound of a `decimal` as a string (or a whole number) with at most the field's `scale` fractional digits: `max = "9.99"`. | | Proposed (D88), not built: `M: rule "K": ...` or `M.rule.K: ...` | A model rule that does not parse, is not true/false, has a malformed or repeated key, follows a reference by more than one hop, or reads a field that a rule may not read. Read the message. | | Proposed (D89), not built: `M.F: on_delete is only valid for kind "ref"` or `on_delete must be refuse, cascade or set_null` | Put `on_delete` on a ref field only, with one of the three values; `set_null` is refused on a `required` field. | | `M: bad action name "X"` | Not an identifier, or one of `get`, `list`, `search`, `create`, `bulk_create`, `update`, `delete`. | @@ -581,7 +587,7 @@ message = "Verifikationen balanserar inte" **Model rules (Proposed, D88; not built).** A model may declare up to 8 `[[model.rule]]` tables, each with a `key`, an `expr` (true/false), an English `message` and `applies`. The expression may use the record's own fields, `id` and one field of the record that a `ref` field points at (`poll_id.closes_at`), and in a later slice `now()`. Every `create`, `update` and `bulk_create` row, and the record writes of an action's effects, are refused when a rule is false; the refusal is `assertion_failed` with `assert_key` and a new `assert_scope`. Delete is not covered, existing records are not re-checked when a rule is installed, and an unset parent field compares equal to the empty value, so a rule that requires equality needs an explicit `is_set` guard. A core that does not have D88 refuses a package that uses the table. -**Effects** run after the new state is set, in order, in one transaction with it: either the transition and every effect happen, or nothing does, sequence numbers included. They run with the caller's permissions and never more. Common keys: `kind`, `model` (what is written), `when` (an expression on the record after the transition; false skips the effect), `for_each` (repeat per child row of this record, with `$line.field` available; at most 500 in all), `as` (a `create` effect names its record for `$created.name`). Values (in `map`, `set`, `match`, `by`) are a constant (text, whole number, true/false) or `$self.field` (this record after the transition, computed fields included), `$self.id`, `$line.field`, `$line.id`, `$created.name`, `$now` (an instant: RFC 3339 UTC text, to the microsecond into a `datetime` field), `$today` (the date on the wall clock of the platform zone, `OBOP_ZONE`), `$caller` (who ran the action); a text constant that starts with `$` is written `$$`. The kind of every value must fit its target field (a decimal into a decimal that keeps every digit, `$now` into a `datetime`, numbers into number or money, a ref into a ref to that model, a select into a select with at least its options...) and a `create` must set every required field. +**Effects** run after the new state is set, in order, in one transaction with it: either the transition and every effect happen, or nothing does, sequence numbers included. They run with the caller's permissions and never more. Common keys: `kind`, `model` (what is written), `when` (an expression on the record after the transition; false skips the effect), `for_each` (repeat per child row of this record, with `$line.field` available; at most 500 in all), `as` (a `create` effect names its record for `$created.name`). Values (in `map`, `set`, `match`, `by`) are a constant (text, whole number, true/false) or `$self.field` (this record after the transition, computed fields included), `$self.id`, `$line.field`, `$line.id`, `$created.name`, `$now` (an instant: RFC 3339 UTC text, to the microsecond into a `datetime` field), `$today` (the date on the wall clock of the platform zone, `OBOP_ZONE`), `$caller` (who ran the action); a text constant that starts with `$` is written `$$`. The kind of every value must fit its target field (a decimal into a decimal that keeps every digit, `$now` into a `datetime`, numbers into number or money, a ref into a ref to that model, a select into a select with at least its options...) and a `create` must set every required field. The limits of the target field (D90) are not checked when the package is installed but when the action runs: a value that breaks one, whether a constant outside `min` or `max` or a `longtext` copied into a shorter `text` field, refuses the action with `limit_violated` and changes nothing. The refusal keeps its code through the effect's context and says only what the field takes, because the value may come from a field the caller cannot read. | `kind` | Keys | Does | |---|---|---| @@ -646,13 +652,13 @@ Values travel as JSON on REST and MCP, and as `key=value` text on the CLI (which | Kind | JSON | Notes and example | |---|---|---| -| `text` | string | One line. `"MBP-0042"` | -| `longtext` | string | Multi-line (the UI shows a text area). `"line 1\nline 2"` | -| `number` | integer | No floats. Use it for counts, minutes, basis points. `1020` | -| `money` | integer | Minor currency units, never a float or a numeric string. 12.34 EUR is `1234`. The package does not store a currency unless you add a field for it. | +| `text` | string | One line, at most 2,048 characters unless the field declares a `max_length` (up to 10,000; D90, Proposed). `"MBP-0042"` | +| `longtext` | string | Multi-line (the UI shows a text area), at most 100,000 characters unless the field declares a `max_length` (up to 1,000,000; D90, Proposed). `"line 1\nline 2"` | +| `number` | integer | No floats. Use it for counts, minutes, basis points. `1020`. May declare `min` and `max` (D90). | +| `money` | integer | Minor currency units, never a float or a numeric string. 12.34 EUR is `1234`. The package does not store a currency unless you add a field for it. May declare `min` and `max` in minor units (D90). | | `date` | string `YYYY-MM-DD` | Must be a real calendar date (leap years honoured). `"2026-01-31"` | | `datetime` | string, RFC 3339 | An instant with microsecond precision (D72). Input needs an explicit offset or `Z`: `"2026-03-29T03:30:00+02:00"`; a value without one, an impossible date, a leap second (`:60`), more than 6 fractional digits, a lower case `t` or `z`, and years outside 0001 to 9999 (after the offset is applied) are refused. It is stored and answered in UTC with six fractional digits, always: `"2026-03-29T01:30:00.000000Z"`, so the stored strings sort exactly as the instants do and two spellings of one instant are one value. Not a JSON number. | -| `decimal` | string | An exact decimal with declared `precision` and `scale` (D72), sent and answered as a canonical string with exactly `scale` fractional digits: `"12.3400"`, `"-0.05"`, `"7"` at scale 0; never a JSON number (a number, including `12`, is refused with `must be ... not a JSON number`). Input is digits with an optional minus sign and an optional point between digits; refused: `+`, exponents (`1e3`), `NaN`, hexadecimal, a decimal comma, thousands separators, spaces, `.5`, `5.`, more fractional digits than the scale (even zeros), and more digits than the precision. Fewer digits are padded (`"12.3"` is stored as `"12.30"`), `-0` is `0`. A field with `round` rounds extra digits only when imported, and then only within the input limits (80 characters and 38 significant digits in all: `round` does not lift them). The OpenAPI schema is `type: string` with a `pattern`; filters compare it as a number, so `"9.5" < "10.25"`. | +| `decimal` | string | An exact decimal with declared `precision` and `scale` (D72), sent and answered as a canonical string with exactly `scale` fractional digits: `"12.3400"`, `"-0.05"`, `"7"` at scale 0; never a JSON number (a number, including `12`, is refused with `must be ... not a JSON number`). Input is digits with an optional minus sign and an optional point between digits; refused: `+`, exponents (`1e3`), `NaN`, hexadecimal, a decimal comma, thousands separators, spaces, `.5`, `5.`, more fractional digits than the scale (even zeros), and more digits than the precision. Fewer digits are padded (`"12.3"` is stored as `"12.30"`), `-0` is `0`. A field with `round` rounds extra digits only when imported, and then only within the input limits (80 characters and 38 significant digits in all: `round` does not lift them). The OpenAPI schema is `type: string` with a `pattern`; filters compare it as a number, so `"9.5" < "10.25"`. May declare `min` and `max` (D90), each a string or a whole number with at most `scale` fractional digits and within the precision: `min = "0.10"`, or `min = 0`, which is `0.00`; OpenAPI says them as `x-obop-minimum` and `x-obop-maximum`. | | `bool` | `true`/`false` | | | `select` | string | One of `options`. `"in_stock"` | | `ref` | string | The id of an existing record of model `to`; a dangling id (or one the caller cannot see, 3.9) is refused. `"9f3c..."` | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 0832004e..4ba3e791 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -738,7 +738,7 @@ Which answers block which gate ([ROADMAP.md s2.3](ROADMAP.md#23-gates)): the G0 | [LICENSE](../LICENSE), [NOTICE](../NOTICE) | The Apache-2.0 licence text and the attribution ("Copyright 2026 SpaceCorps contributors") for the whole project | | [PRODUCT.md](PRODUCT.md) | This document | | [ARCHITECTURE.md](ARCHITECTURE.md) | Reconciled architecture and stack; the CLI, API and MCP surface | -| [DECISIONS.md](DECISIONS.md) | Binding register: the owner rulings OR-1 to OR-35, decisions D1 to D89, vocabulary, waves, the W1 cut and slip order, the open boundary (everything is free), open questions | +| [DECISIONS.md](DECISIONS.md) | Binding register: the owner rulings OR-1 to OR-35, decisions D1 to D90, vocabulary, waves, the W1 cut and slip order, the open boundary (everything is free), open questions | | [CONVENTIONS.md](CONVENTIONS.md) | Naming and contract authority: identifiers, events, permission groups, data classes, ports, CLI grammar | | [ROADMAP.md](ROADMAP.md) | Waves, calendar, CLI-first sequencing, gates, kill criteria; the team is the owner plus AI agents | | [BUSINESS.md](BUSINESS.md) | Licensing and governance, sustainability, community, go-to-market, risk register and counsel items (not a revenue plan) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ff0d89b7..4816483d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -517,7 +517,7 @@ Items 1, 3 and 4 of the earlier list (headcount definition, the seed-to-Series-A ## 10. Sources -Tags map to files relative to this document. D-tags (D1 to D89), F-tags, Q-tags, OR-tags and the sections of DECISIONS.md refer to [DECISIONS.md](DECISIONS.md). Pinned versions and the regulatory calendar come from [R00 s3.2, s3.3, s3.7]. Dates computed in this document (weekdays, day counts, cut and RC dates, the six-week Rust train) were checked by calendar arithmetic. The one other outside fact is the Rust 1.99.0 publication in 5.4, checked on 2026-10-01 against the Rust stable-channel manifest and the release announcement (verified). Module ids and names follow [CONVENTIONS.md](CONVENTIONS.md); command names follow [spec/cli.md](spec/cli.md). +Tags map to files relative to this document. D-tags (D1 to D90), F-tags, Q-tags, OR-tags and the sections of DECISIONS.md refer to [DECISIONS.md](DECISIONS.md). Pinned versions and the regulatory calendar come from [R00 s3.2, s3.3, s3.7]. Dates computed in this document (weekdays, day counts, cut and RC dates, the six-week Rust train) were checked by calendar arithmetic. The one other outside fact is the Rust 1.99.0 publication in 5.4, checked on 2026-10-01 against the Rust stable-channel manifest and the release announcement (verified). Module ids and names follow [CONVENTIONS.md](CONVENTIONS.md); command names follow [spec/cli.md](spec/cli.md). | Tag | File | |---|---| diff --git a/docs/spec/cli.md b/docs/spec/cli.md index 3d9d7a00..a7ad7311 100644 --- a/docs/spec/cli.md +++ b/docs/spec/cli.md @@ -62,7 +62,7 @@ The `obop` binary exists (`crates/obop`, `obop --help` prints one usage page; th | `migrate status\|verify\|plan\|run\|rehearse\|rollback`; `update check\|status\|start\|rollback`; `platform release ...`, `platform upgrade-instances` | `migrate`, `upgrade` (3.1) | | `backup`, `backup verify`, `backup run\|list\|prune`, `restore`, `drill`, `storage check\|attest\|show`, `secrets status\|rotate` | `backup create\|verify\|list\|restore`, `doctor` (3.1) | -Global options built today: `--db URL` (or `OBOP_DATABASE_URL`), `--token` (or `OBOP_TOKEN`), `--demo`, `--dev-auth`, `--version` (one line: `obop 0.2.0 (abcdef012345)`, the core version and the first 12 digits of the commit the binary was built from, or `(unknown)`; docs/DEPLOY.md "The published image"). None of the global flags of 2.2, no profiles, no `--format`, no `--dry-run` as a global flag (several commands have their own `--dry-run`), no `--plan`, no `--idempotency-key` (only `import --key`), no shell completions, no `config.toml` and no login flow exist yet. Output is the command's own text or JSON; there is no common envelope (2.3). The REST surface is `POST /api/{model}/{command}` with bearer tokens, not the dated `/api/v1` of 2.1. Every command of this table except `serve`, `platform serve`, the hosted `/mcp` endpoint, `openapi` and `modules check FILE` opens the database itself (`--db` or `OBOP_DATABASE_URL`, with a token for the business commands) and works without a running server; the CLI has no HTTP client yet, so section 1.3 (a thin HTTP client of `/api/v1`, no direct-database mode) is a Proposed change from today's design. The commands that act on a database and have no API route or page (`migrate`, `backup`, `restore`, `drill`, `storage`, `secrets`) are why 1.1 proposes them as CLI-only entries. Exit statuses built today are in 2.4. +Global options built today: `--db URL` (or `OBOP_DATABASE_URL`), `--token` (or `OBOP_TOKEN`), `--demo`, `--dev-auth`, `--version` (one line: `obop 0.2.1 (abcdef012345)`, the core version and the first 12 digits of the commit the binary was built from, or `(unknown)`; docs/DEPLOY.md "The published image"). None of the global flags of 2.2, no profiles, no `--format`, no `--dry-run` as a global flag (several commands have their own `--dry-run`), no `--plan`, no `--idempotency-key` (only `import --key`), no shell completions, no `config.toml` and no login flow exist yet. Output is the command's own text or JSON; there is no common envelope (2.3). The REST surface is `POST /api/{model}/{command}` with bearer tokens, not the dated `/api/v1` of 2.1. Every command of this table except `serve`, `platform serve`, the hosted `/mcp` endpoint, `openapi` and `modules check FILE` opens the database itself (`--db` or `OBOP_DATABASE_URL`, with a token for the business commands) and works without a running server; the CLI has no HTTP client yet, so section 1.3 (a thin HTTP client of `/api/v1`, no direct-database mode) is a Proposed change from today's design. The commands that act on a database and have no API route or page (`migrate`, `backup`, `restore`, `drill`, `storage`, `secrets`) are why 1.1 proposes them as CLI-only entries. Exit statuses built today are in 2.4. Everything else in sections 2 to 11 (the plugin registry client, `obop.lock`, `init`, `upgrade`, `business`, `pack`, `plan`, `schema`, `login`, completions, man pages, telemetry choices, the golden paths) is Proposed and not built. diff --git a/docs/spec/finance.md b/docs/spec/finance.md index 017ff9ec..a1cb1351 100644 --- a/docs/spec/finance.md +++ b/docs/spec/finance.md @@ -784,7 +784,7 @@ Kept from Odoo (behaviour from public docs, Odoo only as a black-box oracle, F3) ## 10. Sources -Decisions and rulings cited by ID (D1-D89, OR-1 to OR-35, F3 to F6, I-1 to I-4, Q1-Q61) are in [DECISIONS.md](../DECISIONS.md); names of events, permission groups, data classes, states, account roles and ports follow [CONVENTIONS.md](../CONVENTIONS.md); the command grammar and output contracts are in [cli.md](cli.md); neighbouring specs are linked in section 1.3. +Decisions and rulings cited by ID (D1-D90, OR-1 to OR-35, F3 to F6, I-1 to I-4, Q1-Q61) are in [DECISIONS.md](../DECISIONS.md); names of events, permission groups, data classes, states, account roles and ports follow [CONVENTIONS.md](../CONVENTIONS.md); the command grammar and output contracts are in [cli.md](cli.md); neighbouring specs are linked in section 1.3. | Tag | File | |---|---| diff --git a/docs/spec/integrations.md b/docs/spec/integrations.md index af02727d..bd16bdf0 100644 --- a/docs/spec/integrations.md +++ b/docs/spec/integrations.md @@ -655,6 +655,6 @@ Tags map to files relative to `docs/spec/`; "D" identifiers refer to [DECISIONS. | R26 | [research/26-business-model-pricing-risk.md](../research/26-business-model-pricing-risk.md) | | R27 | [research/27-quality-release-engineering.md](../research/27-quality-release-engineering.md) | | R28 | [research/28-docs-community-support-brand.md](../research/28-docs-community-support-brand.md) | -| D1-D89, OR-1 to OR-35, Q1-Q61 | [DECISIONS.md](../DECISIONS.md) | +| D1-D90, OR-1 to OR-35, Q1-Q61 | [DECISIONS.md](../DECISIONS.md) | | CLI | [cli.md](cli.md) (the agentic-first command surface and the plugin registry client) | | CONVENTIONS | [CONVENTIONS.md](../CONVENTIONS.md) (spelling of cross-document names; subordinate to DECISIONS.md) | diff --git a/docs/spec/security-privacy.md b/docs/spec/security-privacy.md index 05094194..8e4dec74 100644 --- a/docs/spec/security-privacy.md +++ b/docs/spec/security-privacy.md @@ -728,7 +728,7 @@ P1 (before the first public release; not cut by D86): envelope encryption, signe ## 10. Sources -Decision IDs (D1-D89, OR-1 to OR-35, I-1 to I-4, F1-F6) refer to [../DECISIONS.md](../DECISIONS.md); Q-numbers are its owner questions (section 8). The command grammar, the catalogue and the plugin registry client are [cli.md](cli.md). The licence texts are [../../LICENSE](../../LICENSE) (Apache-2.0) and [../../NOTICE](../../NOTICE). Non-research facts: WCAG 2.2 success criterion 3.3.8 (Accessible Authentication, Minimum, Level AA): (existence and level confirmed 2026-10-01); Regulation (EU) 2026/1744 of 8 July 2026 (Digital Omnibus on AI; CELEX 32026R1744, ), identification and dates recorded in the 2026-10-01 reconciliation pass, corrigendum of 2026-09-29 (OJ L 2026/90810) not read; vite-plus v1.0.0 `vp pm` command set, checked against its source and the installed `vp` on 2026-10-01. +Decision IDs (D1-D90, OR-1 to OR-35, I-1 to I-4, F1-F6) refer to [../DECISIONS.md](../DECISIONS.md); Q-numbers are its owner questions (section 8). The command grammar, the catalogue and the plugin registry client are [cli.md](cli.md). The licence texts are [../../LICENSE](../../LICENSE) (Apache-2.0) and [../../NOTICE](../../NOTICE). Non-research facts: WCAG 2.2 success criterion 3.3.8 (Accessible Authentication, Minimum, Level AA): (existence and level confirmed 2026-10-01); Regulation (EU) 2026/1744 of 8 July 2026 (Digital Omnibus on AI; CELEX 32026R1744, ), identification and dates recorded in the 2026-10-01 reconciliation pass, corrigendum of 2026-09-29 (OJ L 2026/90810) not read; vite-plus v1.0.0 `vp pm` command set, checked against its source and the installed `vp` on 2026-10-01. | Tag | File | |---|---| diff --git a/docs/spec/services.md b/docs/spec/services.md index a78252c8..5590e87e 100644 --- a/docs/spec/services.md +++ b/docs/spec/services.md @@ -662,7 +662,7 @@ The last column names an [R04] pain rank only where one applies. ## 10. Sources -Decision IDs (D1-D89), the owner rulings OR-1 to OR-35, register sections and vocabulary come from [DECISIONS.md](../DECISIONS.md). Names of events, permission groups, data classes and entities follow [CONVENTIONS.md](../CONVENTIONS.md); command names and the CLI grammar follow [cli.md](cli.md) (Proposed until Q22). +Decision IDs (D1-D90), the owner rulings OR-1 to OR-35, register sections and vocabulary come from [DECISIONS.md](../DECISIONS.md). Names of events, permission groups, data classes and entities follow [CONVENTIONS.md](../CONVENTIONS.md); command names and the CLI grammar follow [cli.md](cli.md) (Proposed until Q22). | Tag | File | |---|---| diff --git a/docs/spec/ux.md b/docs/spec/ux.md index 7e98ee7a..d030c747 100644 --- a/docs/spec/ux.md +++ b/docs/spec/ux.md @@ -660,7 +660,7 @@ Pain ranks are from the voice-of-customer study [R04 s1]: 1 upgrades, 2 open-cor ## 10. Sources -Decision IDs (D1 to D89), owner rulings (OR-1 to OR-35) and owner questions (Q1 to Q61) refer to [DECISIONS.md](../DECISIONS.md); the spellings of shared names are those of [CONVENTIONS.md](../CONVENTIONS.md). Research tags map to the files below; "s" numbers are section numbers inside each file, and "Implications n" refers to the numbered list in its "Implications for obop" section. +Decision IDs (D1 to D90), owner rulings (OR-1 to OR-35) and owner questions (Q1 to Q61) refer to [DECISIONS.md](../DECISIONS.md); the spellings of shared names are those of [CONVENTIONS.md](../CONVENTIONS.md). Research tags map to the files below; "s" numbers are section numbers inside each file, and "Implications n" refers to the numbered list in its "Implications for obop" section. | Tag | File | |---|---| diff --git a/web/apps/backoffice/locales/de/messages.json b/web/apps/backoffice/locales/de/messages.json index bf4ac893..d6efd033 100644 --- a/web/apps/backoffice/locales/de/messages.json +++ b/web/apps/backoffice/locales/de/messages.json @@ -167,6 +167,7 @@ "Close": "Schließen", "Close module": "Modul schließen", "Close record": "Datensatz schließen", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Nahe am Limit von {max, plural, one {# Zeichen} other {# Zeichen}}", "Collecting the matching records: {done} of {total}": "Passende Datensätze werden gesammelt: {done} von {total}", "Colour": "Farbe", "Colour of {name}": "Farbe von {name}", @@ -619,6 +620,7 @@ "Other modules need {name}": "Andere Module benötigen {name}", "Outbound": "Ausgehend", "Outcome": "Ergebnis", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Über dem Limit von {max, plural, one {# Zeichen} other {# Zeichen}}", "Overview": "Übersicht", "Pair": "Paar", "Part of the core": "Teil des Kerns", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "{field} muss eine ganze Zahl sein", "{field} must be an amount": "{field} muss ein Betrag sein", "{field} must be an amount like {example}": "{field} muss ein Betrag wie {example} sein", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} muss mindestens {min, plural, one {# Zeichen} other {# Zeichen}} lang sein; hier sind es {have}", + "{field} must be at least {min}; this is {value}": "{field} muss mindestens {min} sein; der Wert ist {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} darf höchstens {max, plural, one {# Zeichen} other {# Zeichen}} lang sein; hier sind es {have}", + "{field} must be at most {max}; this is {value}": "{field} darf höchstens {max} sein; der Wert ist {value}", "{field} must be between the years 0001 and 9999": "{field} muss zwischen den Jahren 0001 und 9999 liegen", "{field} must be one of: {options}": "{field} muss einer dieser Werte sein: {options}", "{field} must be text": "{field} muss Text sein", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "{ready} von {total} können jetzt aktualisiert werden. Bei einem Modul mit blockiertem Update steht der Grund auf seiner Karte.", "{row}, {column}: {value}": "{row}, {column}: {value}", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} von {max, plural, one {# Zeichen} other {# Zeichen}}", "{user} can sign in again.": "{user} kann sich wieder anmelden.", "{user} is disabled and signed out.": "{user} ist deaktiviert und abgemeldet.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} wurde ausgeschieden: {sessions} Sitzung(en), {tokens} Agent-Token und {apps} verbundene App(s) endgültig beendet.", diff --git a/web/apps/backoffice/locales/de/status.json b/web/apps/backoffice/locales/de/status.json index 602cdab2..e9e58d06 100644 --- a/web/apps/backoffice/locales/de/status.json +++ b/web/apps/backoffice/locales/de/status.json @@ -167,6 +167,7 @@ "Close": "machine-drafted", "Close module": "machine-drafted", "Close record": "machine-drafted", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Collecting the matching records: {done} of {total}": "machine-drafted", "Colour": "machine-drafted", "Colour of {name}": "machine-drafted", @@ -619,6 +620,7 @@ "Other modules need {name}": "machine-drafted", "Outbound": "machine-drafted", "Outcome": "machine-drafted", + "Over the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Overview": "machine-drafted", "Pair": "machine-drafted", "Part of the core": "machine-drafted", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "machine-drafted", "{field} must be an amount": "machine-drafted", "{field} must be an amount like {example}": "machine-drafted", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at least {min}; this is {value}": "machine-drafted", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at most {max}; this is {value}": "machine-drafted", "{field} must be between the years 0001 and 9999": "machine-drafted", "{field} must be one of: {options}": "machine-drafted", "{field} must be text": "machine-drafted", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "machine-drafted", "{row}, {column}: {value}": "machine-drafted", "{title}. {values}.": "machine-drafted", + "{used} of {max, plural, one {# character} other {# characters}}": "machine-drafted", "{user} can sign in again.": "machine-drafted", "{user} is disabled and signed out.": "machine-drafted", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "machine-drafted", diff --git a/web/apps/backoffice/locales/en/messages.json b/web/apps/backoffice/locales/en/messages.json index 4d0c60b7..a91b9665 100644 --- a/web/apps/backoffice/locales/en/messages.json +++ b/web/apps/backoffice/locales/en/messages.json @@ -66,6 +66,10 @@ "{field} must be a whole number": "{field} must be a whole number", "{field} must be an amount": "{field} must be an amount", "{field} must be an amount like {example}": "{field} must be an amount like {example}", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}", + "{field} must be at least {min}; this is {value}": "{field} must be at least {min}; this is {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}", + "{field} must be at most {max}; this is {value}": "{field} must be at most {max}; this is {value}", "{field} must be between the years 0001 and 9999": "{field} must be between the years 0001 and 9999", "{field} must be one of: {options}": "{field} must be one of: {options}", "{field} must be text": "{field} must be text", @@ -132,6 +136,7 @@ "{row}, {column}: {value}": "{row}, {column}: {value}", "{title}: {value}. Open the list.": "{title}: {value}. Open the list.", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} of {max, plural, one {# character} other {# characters}}", "{user} can sign in again.": "{user} can sign in again.", "{user} is disabled and signed out.": "{user} is disabled and signed out.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.", @@ -409,6 +414,7 @@ "Close": "Close", "Close module": "Close module", "Close record": "Close record", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Close to the limit of {max, plural, one {# character} other {# characters}}", "Collecting the matching records: {done} of {total}": "Collecting the matching records: {done} of {total}", "Colour": "Colour", "Colour of {name}": "Colour of {name}", @@ -1400,6 +1406,7 @@ "Other modules need {name}": "Other modules need {name}", "Outbound": "Outbound", "Outcome": "Outcome", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Over the limit of {max, plural, one {# character} other {# characters}}", "Overview": "Overview", "Pair": "Pair", "Part of the core": "Part of the core", diff --git a/web/apps/backoffice/locales/es/messages.json b/web/apps/backoffice/locales/es/messages.json index 194b4ef6..7899667c 100644 --- a/web/apps/backoffice/locales/es/messages.json +++ b/web/apps/backoffice/locales/es/messages.json @@ -167,6 +167,7 @@ "Close": "Cerrar", "Close module": "Cerrar módulo", "Close record": "Cerrar registro", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Cerca del límite de {max, plural, one {# carácter} other {# caracteres}}", "Collecting the matching records: {done} of {total}": "Recopilando los registros coincidentes: {done} de {total}", "Colour": "Color", "Colour of {name}": "Color de {name}", @@ -619,6 +620,7 @@ "Other modules need {name}": "Otros módulos necesitan {name}", "Outbound": "Saliente", "Outcome": "Resultado", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Por encima del límite de {max, plural, one {# carácter} other {# caracteres}}", "Overview": "Resumen", "Pair": "Par", "Part of the core": "Parte del núcleo", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "{field} debe ser un número entero", "{field} must be an amount": "{field} debe ser un importe", "{field} must be an amount like {example}": "{field} debe ser un importe como {example}", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} debe tener al menos {min, plural, one {# carácter} other {# caracteres}}; tiene {have}", + "{field} must be at least {min}; this is {value}": "{field} debe ser como mínimo {min}; el valor es {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} debe tener como máximo {max, plural, one {# carácter} other {# caracteres}}; tiene {have}", + "{field} must be at most {max}; this is {value}": "{field} debe ser como máximo {max}; el valor es {value}", "{field} must be between the years 0001 and 9999": "{field} debe estar entre los años 0001 y 9999", "{field} must be one of: {options}": "{field} debe ser uno de estos valores: {options}", "{field} must be text": "{field} debe ser texto", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "{ready} de {total} se pueden actualizar ahora. Si la actualización de un módulo está bloqueada, su tarjeta indica el motivo.", "{row}, {column}: {value}": "{row}, {column}: {value}", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} de {max, plural, one {# carácter} other {# caracteres}}", "{user} can sign in again.": "{user} puede volver a iniciar sesión.", "{user} is disabled and signed out.": "{user} está desactivado y con la sesión cerrada.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} se ha dado de baja: {sessions} sesión(es), {tokens} token(s) de agente y {apps} aplicación(es) conectada(s) terminadas definitivamente.", diff --git a/web/apps/backoffice/locales/es/status.json b/web/apps/backoffice/locales/es/status.json index 602cdab2..e9e58d06 100644 --- a/web/apps/backoffice/locales/es/status.json +++ b/web/apps/backoffice/locales/es/status.json @@ -167,6 +167,7 @@ "Close": "machine-drafted", "Close module": "machine-drafted", "Close record": "machine-drafted", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Collecting the matching records: {done} of {total}": "machine-drafted", "Colour": "machine-drafted", "Colour of {name}": "machine-drafted", @@ -619,6 +620,7 @@ "Other modules need {name}": "machine-drafted", "Outbound": "machine-drafted", "Outcome": "machine-drafted", + "Over the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Overview": "machine-drafted", "Pair": "machine-drafted", "Part of the core": "machine-drafted", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "machine-drafted", "{field} must be an amount": "machine-drafted", "{field} must be an amount like {example}": "machine-drafted", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at least {min}; this is {value}": "machine-drafted", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at most {max}; this is {value}": "machine-drafted", "{field} must be between the years 0001 and 9999": "machine-drafted", "{field} must be one of: {options}": "machine-drafted", "{field} must be text": "machine-drafted", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "machine-drafted", "{row}, {column}: {value}": "machine-drafted", "{title}. {values}.": "machine-drafted", + "{used} of {max, plural, one {# character} other {# characters}}": "machine-drafted", "{user} can sign in again.": "machine-drafted", "{user} is disabled and signed out.": "machine-drafted", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "machine-drafted", diff --git a/web/apps/backoffice/locales/fi/messages.json b/web/apps/backoffice/locales/fi/messages.json index 88d7c4c9..0b23f823 100644 --- a/web/apps/backoffice/locales/fi/messages.json +++ b/web/apps/backoffice/locales/fi/messages.json @@ -167,6 +167,7 @@ "Close": "Sulje", "Close module": "Sulje moduuli", "Close record": "Sulje tietue", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Lähellä rajaa: {max, plural, one {# merkki} other {# merkkiä}}", "Collecting the matching records: {done} of {total}": "Kerätään osuvia tietueita: {done} / {total}", "Colour": "Väri", "Colour of {name}": "Väri: {name}", @@ -619,6 +620,7 @@ "Other modules need {name}": "Muut moduulit tarvitsevat moduulia {name}", "Outbound": "Lähtevä", "Outcome": "Tulos", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Yli rajan: {max, plural, one {# merkki} other {# merkkiä}}", "Overview": "Yleiskatsaus", "Pair": "Pari", "Part of the core": "Osa ydintä", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "{field} on oltava kokonaisluku", "{field} must be an amount": "{field} on oltava rahamäärä", "{field} must be an amount like {example}": "{field} on oltava rahamäärä, esimerkiksi {example}", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} on oltava vähintään {min, plural, one {# merkki} other {# merkkiä}} pitkä; nyt merkkejä on {have}", + "{field} must be at least {min}; this is {value}": "{field} on oltava vähintään {min}; arvo on {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} saa olla enintään {max, plural, one {# merkki} other {# merkkiä}} pitkä; nyt merkkejä on {have}", + "{field} must be at most {max}; this is {value}": "{field} saa olla enintään {max}; arvo on {value}", "{field} must be between the years 0001 and 9999": "{field} on oltava vuosien 0001 ja 9999 välillä", "{field} must be one of: {options}": "{field} on oltava jokin seuraavista: {options}", "{field} must be text": "{field} on oltava tekstiä", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "{ready} / {total} voidaan päivittää nyt. Moduulin kortti kertoo, miksi sen päivitys on estetty.", "{row}, {column}: {value}": "{row}, {column}: {value}", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} / {max, plural, one {# merkki} other {# merkkiä}}", "{user} can sign in again.": "{user} voi kirjautua taas sisään.", "{user} is disabled and signed out.": "{user} on poistettu käytöstä ja kirjattu ulos.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} on poistettu käytöstä lopullisesti: {sessions} istuntoa, {tokens} agenttitunnistetta ja {apps} yhdistettyä sovellusta päätetty pysyvästi.", diff --git a/web/apps/backoffice/locales/fi/status.json b/web/apps/backoffice/locales/fi/status.json index 602cdab2..e9e58d06 100644 --- a/web/apps/backoffice/locales/fi/status.json +++ b/web/apps/backoffice/locales/fi/status.json @@ -167,6 +167,7 @@ "Close": "machine-drafted", "Close module": "machine-drafted", "Close record": "machine-drafted", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Collecting the matching records: {done} of {total}": "machine-drafted", "Colour": "machine-drafted", "Colour of {name}": "machine-drafted", @@ -619,6 +620,7 @@ "Other modules need {name}": "machine-drafted", "Outbound": "machine-drafted", "Outcome": "machine-drafted", + "Over the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Overview": "machine-drafted", "Pair": "machine-drafted", "Part of the core": "machine-drafted", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "machine-drafted", "{field} must be an amount": "machine-drafted", "{field} must be an amount like {example}": "machine-drafted", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at least {min}; this is {value}": "machine-drafted", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at most {max}; this is {value}": "machine-drafted", "{field} must be between the years 0001 and 9999": "machine-drafted", "{field} must be one of: {options}": "machine-drafted", "{field} must be text": "machine-drafted", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "machine-drafted", "{row}, {column}: {value}": "machine-drafted", "{title}. {values}.": "machine-drafted", + "{used} of {max, plural, one {# character} other {# characters}}": "machine-drafted", "{user} can sign in again.": "machine-drafted", "{user} is disabled and signed out.": "machine-drafted", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "machine-drafted", diff --git a/web/apps/backoffice/locales/fr/messages.json b/web/apps/backoffice/locales/fr/messages.json index 65d1e87e..6ed980d5 100644 --- a/web/apps/backoffice/locales/fr/messages.json +++ b/web/apps/backoffice/locales/fr/messages.json @@ -167,6 +167,7 @@ "Close": "Fermer", "Close module": "Fermer le module", "Close record": "Fermer l’enregistrement", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Proche de la limite de {max, plural, one {# caractère} other {# caractères}}", "Collecting the matching records: {done} of {total}": "Collecte des enregistrements correspondants : {done} sur {total}", "Colour": "Couleur", "Colour of {name}": "Couleur de {name}", @@ -619,6 +620,7 @@ "Other modules need {name}": "D’autres modules ont besoin de {name}", "Outbound": "Sortant", "Outcome": "Résultat", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Au-dessus de la limite de {max, plural, one {# caractère} other {# caractères}}", "Overview": "Vue d’ensemble", "Pair": "Combinaison", "Part of the core": "Fait partie du noyau", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "{field} doit être un nombre entier", "{field} must be an amount": "{field} doit être un montant", "{field} must be an amount like {example}": "{field} doit être un montant, par exemple {example}", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} doit comporter au moins {min, plural, one {# caractère} other {# caractères}} ; il y en a {have}", + "{field} must be at least {min}; this is {value}": "{field} doit être supérieur ou égal à {min} ; la valeur est {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} doit comporter au plus {max, plural, one {# caractère} other {# caractères}} ; il y en a {have}", + "{field} must be at most {max}; this is {value}": "{field} doit être inférieur ou égal à {max} ; la valeur est {value}", "{field} must be between the years 0001 and 9999": "{field} doit se situer entre les années 0001 et 9999", "{field} must be one of: {options}": "{field} doit avoir l’une des valeurs suivantes : {options}", "{field} must be text": "{field} doit être du texte", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "{ready} sur {total} peuvent être mis à jour maintenant. Lorsque la mise à jour d’un module est bloquée, sa carte en indique la raison.", "{row}, {column}: {value}": "{row}, {column} : {value}", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} sur {max, plural, one {# caractère} other {# caractères}}", "{user} can sign in again.": "{user} peut de nouveau se connecter.", "{user} is disabled and signed out.": "{user} est désactivé et déconnecté.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} est hors service : {sessions} session(s), {tokens} jeton(s) d’agent et {apps} application(s) connectée(s) définitivement terminés.", diff --git a/web/apps/backoffice/locales/fr/status.json b/web/apps/backoffice/locales/fr/status.json index 602cdab2..e9e58d06 100644 --- a/web/apps/backoffice/locales/fr/status.json +++ b/web/apps/backoffice/locales/fr/status.json @@ -167,6 +167,7 @@ "Close": "machine-drafted", "Close module": "machine-drafted", "Close record": "machine-drafted", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Collecting the matching records: {done} of {total}": "machine-drafted", "Colour": "machine-drafted", "Colour of {name}": "machine-drafted", @@ -619,6 +620,7 @@ "Other modules need {name}": "machine-drafted", "Outbound": "machine-drafted", "Outcome": "machine-drafted", + "Over the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Overview": "machine-drafted", "Pair": "machine-drafted", "Part of the core": "machine-drafted", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "machine-drafted", "{field} must be an amount": "machine-drafted", "{field} must be an amount like {example}": "machine-drafted", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at least {min}; this is {value}": "machine-drafted", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at most {max}; this is {value}": "machine-drafted", "{field} must be between the years 0001 and 9999": "machine-drafted", "{field} must be one of: {options}": "machine-drafted", "{field} must be text": "machine-drafted", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "machine-drafted", "{row}, {column}: {value}": "machine-drafted", "{title}. {values}.": "machine-drafted", + "{used} of {max, plural, one {# character} other {# characters}}": "machine-drafted", "{user} can sign in again.": "machine-drafted", "{user} is disabled and signed out.": "machine-drafted", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "machine-drafted", diff --git a/web/apps/backoffice/locales/pl/messages.json b/web/apps/backoffice/locales/pl/messages.json index b298bef3..6a080353 100644 --- a/web/apps/backoffice/locales/pl/messages.json +++ b/web/apps/backoffice/locales/pl/messages.json @@ -167,6 +167,7 @@ "Close": "Zamknij", "Close module": "Zamknij moduł", "Close record": "Zamknij rekord", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Blisko limitu {max, plural, one {# znaku} few {# znaków} many {# znaków} other {# znaku}}", "Collecting the matching records: {done} of {total}": "Zbieranie pasujących rekordów: {done} z {total}", "Colour": "Kolor", "Colour of {name}": "Kolor: {name}", @@ -619,6 +620,7 @@ "Other modules need {name}": "Inne moduły potrzebują modułu {name}", "Outbound": "Wychodzący", "Outcome": "Wynik", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Przekroczono limit {max, plural, one {# znaku} few {# znaków} many {# znaków} other {# znaku}}", "Overview": "Przegląd", "Pair": "Para", "Part of the core": "Część rdzenia", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "Pole „{field}” musi być liczbą całkowitą", "{field} must be an amount": "Pole „{field}” musi być kwotą", "{field} must be an amount like {example}": "Pole „{field}” musi być kwotą, np. {example}", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} musi mieć co najmniej {min, plural, one {# znak} few {# znaki} many {# znaków} other {# znaku}}; wpisano {have}", + "{field} must be at least {min}; this is {value}": "{field} musi wynosić co najmniej {min}; wartość to {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} może mieć co najwyżej {max, plural, one {# znak} few {# znaki} many {# znaków} other {# znaku}}; wpisano {have}", + "{field} must be at most {max}; this is {value}": "{field} może wynosić co najwyżej {max}; wartość to {value}", "{field} must be between the years 0001 and 9999": "{field} musi mieścić się między rokiem 0001 a 9999", "{field} must be one of: {options}": "Pole „{field}” musi mieć jedną z wartości: {options}", "{field} must be text": "Pole „{field}” musi być tekstem", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "{ready} z {total} można teraz zaktualizować. Gdy aktualizacja modułu jest zablokowana, jego karta podaje powód.", "{row}, {column}: {value}": "{row}, {column}: {value}", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} z {max, plural, one {# znaku} few {# znaków} many {# znaków} other {# znaku}}", "{user} can sign in again.": "{user} może się znowu zalogować.", "{user} is disabled and signed out.": "Konto {user} jest wyłączone, a użytkownik wylogowany.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} został wyłączony z firmy: {sessions} sesji, {tokens} tokenów agentów i {apps} połączonych aplikacji zakończono na stałe.", diff --git a/web/apps/backoffice/locales/pl/status.json b/web/apps/backoffice/locales/pl/status.json index 602cdab2..e9e58d06 100644 --- a/web/apps/backoffice/locales/pl/status.json +++ b/web/apps/backoffice/locales/pl/status.json @@ -167,6 +167,7 @@ "Close": "machine-drafted", "Close module": "machine-drafted", "Close record": "machine-drafted", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Collecting the matching records: {done} of {total}": "machine-drafted", "Colour": "machine-drafted", "Colour of {name}": "machine-drafted", @@ -619,6 +620,7 @@ "Other modules need {name}": "machine-drafted", "Outbound": "machine-drafted", "Outcome": "machine-drafted", + "Over the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Overview": "machine-drafted", "Pair": "machine-drafted", "Part of the core": "machine-drafted", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "machine-drafted", "{field} must be an amount": "machine-drafted", "{field} must be an amount like {example}": "machine-drafted", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at least {min}; this is {value}": "machine-drafted", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at most {max}; this is {value}": "machine-drafted", "{field} must be between the years 0001 and 9999": "machine-drafted", "{field} must be one of: {options}": "machine-drafted", "{field} must be text": "machine-drafted", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "machine-drafted", "{row}, {column}: {value}": "machine-drafted", "{title}. {values}.": "machine-drafted", + "{used} of {max, plural, one {# character} other {# characters}}": "machine-drafted", "{user} can sign in again.": "machine-drafted", "{user} is disabled and signed out.": "machine-drafted", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "machine-drafted", diff --git a/web/apps/backoffice/locales/sv/messages.json b/web/apps/backoffice/locales/sv/messages.json index 227c08b5..884bce12 100644 --- a/web/apps/backoffice/locales/sv/messages.json +++ b/web/apps/backoffice/locales/sv/messages.json @@ -167,6 +167,7 @@ "Close": "Stäng", "Close module": "Stäng modulen", "Close record": "Stäng posten", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "Nära gränsen på {max, plural, one {# tecken} other {# tecken}}", "Collecting the matching records: {done} of {total}": "Samlar in de matchande posterna: {done} av {total}", "Colour": "Färg", "Colour of {name}": "Färg för {name}", @@ -619,6 +620,7 @@ "Other modules need {name}": "Andra moduler behöver {name}", "Outbound": "Utgående", "Outcome": "Utfall", + "Over the limit of {max, plural, one {# character} other {# characters}}": "Över gränsen på {max, plural, one {# tecken} other {# tecken}}", "Overview": "Översikt", "Pair": "Par", "Part of the core": "En del av kärnan", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "{field} måste vara ett heltal", "{field} must be an amount": "{field} måste vara ett belopp", "{field} must be an amount like {example}": "{field} måste vara ett belopp, till exempel {example}", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "{field} måste vara minst {min, plural, one {# tecken} other {# tecken}} långt; nu är det {have}", + "{field} must be at least {min}; this is {value}": "{field} måste vara minst {min}; värdet är {value}", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "{field} får vara högst {max, plural, one {# tecken} other {# tecken}} långt; nu är det {have}", + "{field} must be at most {max}; this is {value}": "{field} får vara högst {max}; värdet är {value}", "{field} must be between the years 0001 and 9999": "{field} måste ligga mellan åren 0001 och 9999", "{field} must be one of: {options}": "{field} måste vara något av: {options}", "{field} must be text": "{field} måste vara text", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "{ready} av {total} kan uppdateras nu. På kortet för en modul vars uppdatering är blockerad står det varför.", "{row}, {column}: {value}": "{row}, {column}: {value}", "{title}. {values}.": "{title}. {values}.", + "{used} of {max, plural, one {# character} other {# characters}}": "{used} av {max, plural, one {# tecken} other {# tecken}}", "{user} can sign in again.": "{user} kan logga in igen.", "{user} is disabled and signed out.": "{user} är inaktiverad och utloggad.", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "{user} är avslutad: {sessions} session(er), {tokens} agent-token och {apps} anslutna appar har avslutats för gott.", diff --git a/web/apps/backoffice/locales/sv/status.json b/web/apps/backoffice/locales/sv/status.json index 602cdab2..e9e58d06 100644 --- a/web/apps/backoffice/locales/sv/status.json +++ b/web/apps/backoffice/locales/sv/status.json @@ -167,6 +167,7 @@ "Close": "machine-drafted", "Close module": "machine-drafted", "Close record": "machine-drafted", + "Close to the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Collecting the matching records: {done} of {total}": "machine-drafted", "Colour": "machine-drafted", "Colour of {name}": "machine-drafted", @@ -619,6 +620,7 @@ "Other modules need {name}": "machine-drafted", "Outbound": "machine-drafted", "Outcome": "machine-drafted", + "Over the limit of {max, plural, one {# character} other {# characters}}": "machine-drafted", "Overview": "machine-drafted", "Pair": "machine-drafted", "Part of the core": "machine-drafted", @@ -1808,6 +1810,10 @@ "{field} must be a whole number": "machine-drafted", "{field} must be an amount": "machine-drafted", "{field} must be an amount like {example}": "machine-drafted", + "{field} must be at least {min, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at least {min}; this is {value}": "machine-drafted", + "{field} must be at most {max, plural, one {# character} other {# characters}}; this has {have}": "machine-drafted", + "{field} must be at most {max}; this is {value}": "machine-drafted", "{field} must be between the years 0001 and 9999": "machine-drafted", "{field} must be one of: {options}": "machine-drafted", "{field} must be text": "machine-drafted", @@ -1875,6 +1881,7 @@ "{ready} of {total} can be updated now. A module whose update is blocked says why on its card.": "machine-drafted", "{row}, {column}: {value}": "machine-drafted", "{title}. {values}.": "machine-drafted", + "{used} of {max, plural, one {# character} other {# characters}}": "machine-drafted", "{user} can sign in again.": "machine-drafted", "{user} is disabled and signed out.": "machine-drafted", "{user} is offboarded: {sessions} session(s), {tokens} agent token(s) and {apps} connected app(s) ended for good.": "machine-drafted", diff --git a/web/apps/backoffice/src/RecordPanel.tsx b/web/apps/backoffice/src/RecordPanel.tsx index 517b5243..4336425b 100644 --- a/web/apps/backoffice/src/RecordPanel.tsx +++ b/web/apps/backoffice/src/RecordPanel.tsx @@ -178,6 +178,8 @@ export function RecordPanel({ value, creating ? undefined : (f) => changed(f) || f.required, currency, + // A value left as stored is not held to limits lowered since it was written (D90). + (f) => !creating && !changed(f), ); setErrors(errs); if (Object.keys(errs).length) return d.notify(t("Fix the highlighted fields first"), "error"); @@ -569,6 +571,7 @@ export function RecordPanel({ currency={currency} invalid={!!err} describedBy={err ? `${fid}-err` : undefined} + unchanged={!creating && !changed(f)} onChange={(v) => { setDraft({ ...draft, [f.name]: v }); if (err) setErrors({ ...errors, [f.name]: "" }); diff --git a/web/apps/backoffice/src/api.ts b/web/apps/backoffice/src/api.ts index 43dc2c95..ec6a066c 100644 --- a/web/apps/backoffice/src/api.ts +++ b/web/apps/backoffice/src/api.ts @@ -55,6 +55,20 @@ export type FieldMeta = { precision?: number; scale?: number; round?: "half_even" | "half_up" | "down"; + /** + * Only for `text` and `longtext` (D90): the most characters (Unicode code points, so "å" and an + * emoji count one each) a written value has, which is the default of the kind when the package + * declares none, and the fewest, when it declares a minimum. Absent for computed and numbered + * fields. + */ + max_length?: number; + min_length?: number; + /** + * Only for `number`, `money` (minor units) and `decimal` (a canonical string at the scale of the + * field) (D90): the inclusive bounds the package declares. + */ + min?: number | string; + max?: number | string; /** The model the field belongs to (set when the metadata is loaded). */ model?: string; /** Moves only through the model's actions, also for someone who can run none of them (D48). */ diff --git a/web/apps/backoffice/src/edit.ts b/web/apps/backoffice/src/edit.ts index ad204095..349808ff 100644 --- a/web/apps/backoffice/src/edit.ts +++ b/web/apps/backoffice/src/edit.ts @@ -4,6 +4,7 @@ import { canonicalDecimal, decimalInput } from "./decimals.ts"; import { isEmpty, majorString, optionLabel, parseMoney, validDate } from "./format.ts"; import { fromZonedInput, toZonedInput } from "./instants.ts"; import { fieldTitle } from "./labels.ts"; +import { rangeError, textLimitError } from "./limits.ts"; /** What a form control holds: text for everything, a boolean for checkboxes. */ export type Edit = string | boolean; @@ -17,11 +18,16 @@ export function toEdit(f: FieldMeta, v: unknown, currency?: string): Edit { return String(v); } -/** Turn what was typed into the wire value, or say why it cannot be one. `null` means empty. */ +/** + * Turn what was typed into the wire value, or say why it cannot be one. `null` means empty. A value + * the person did not change (`unchanged`) is not held to the length and range limits of the field, + * as on the server (D90): a record stored above a limit that was lowered later can still be saved. + */ export function fromEdit( f: FieldMeta, e: Edit, currency?: string, + unchanged = false, ): { value: unknown } | { error: string } { const field = fieldTitle(f); if (f.kind === "bool") return { value: e === true }; @@ -30,20 +36,23 @@ export function fromEdit( return f.required ? { error: t("{field} is required", { field }) } : { value: null }; } switch (f.kind) { - case "number": - return /^-?\d+$/.test(s) - ? { value: parseInt(s, 10) } - : { error: t("{field} must be a whole number", { field }) }; + case "number": { + if (!/^-?\d+$/.test(s)) return { error: t("{field} must be a whole number", { field }) }; + const n = parseInt(s, 10); + const out = unchanged ? null : rangeError(field, f, n); + return out ? { error: out } : { value: n }; + } case "money": { const m = parseMoney(s, currency); - return m === null - ? { - error: t("{field} must be an amount like {example}", { - field, - example: fmt.moneyInput(1250, currency), - }), - } - : { value: m }; + if (m === null) + return { + error: t("{field} must be an amount like {example}", { + field, + example: fmt.moneyInput(1250, currency), + }), + }; + const out = unchanged ? null : rangeError(field, f, m, currency); + return out ? { error: out } : { value: m }; } case "date": return validDate(s) ? { value: s } : { error: t("{field} must be a valid date", { field }) }; @@ -83,7 +92,9 @@ export function fromEdit( digits, }), }; - return { value: canonicalDecimal(p.negative, p.int, p.frac, scale) }; + const dec = canonicalDecimal(p.negative, p.int, p.frac, scale); + const out = unchanged ? null : rangeError(field, f, dec); + return out ? { error: out } : { value: dec }; } case "datetime": { const { zone } = getTime(); @@ -119,22 +130,29 @@ export function fromEdit( options: (f.options ?? []).map((o) => optionLabel(o, f)).join(", "), }), }; - default: - return { value: e as string }; + default: { + // The server counts what is sent, untrimmed (D90). + const out = unchanged ? null : textLimitError(field, f, String(e)); + return out ? { error: out } : { value: e as string }; + } } } -/** Validate a whole draft; returns errors by field name. */ +/** + * Validate a whole draft; returns errors by field name. `unchanged` says which fields hold the + * stored value, which are not held to the limits of their field (see `fromEdit`). + */ export function validate( fields: FieldMeta[], values: (f: FieldMeta) => Edit, only?: (f: FieldMeta) => boolean, currency?: string, + unchanged?: (f: FieldMeta) => boolean, ): Record { const errors: Record = {}; for (const f of fields) { if (only && !only(f)) continue; - const r = fromEdit(f, values(f), currency); + const r = fromEdit(f, values(f), currency, unchanged?.(f) ?? false); if ("error" in r) errors[f.name] = r.error; } return errors; diff --git a/web/apps/backoffice/src/fields.tsx b/web/apps/backoffice/src/fields.tsx index b0da149b..09d8e128 100644 --- a/web/apps/backoffice/src/fields.tsx +++ b/web/apps/backoffice/src/fields.tsx @@ -15,6 +15,7 @@ import { shownLabel, } from "./format.ts"; import { fieldTitle, modelTitle } from "./labels.ts"; +import { counterState } from "./limits.ts"; import { BlobInput, BlobThumb, FileChip } from "./BlobFields.tsx"; import { Badge, Icon } from "./ui.tsx"; @@ -140,16 +141,74 @@ type InputProps = { altHint?: { title: string; empty: boolean }; /** For a read-only text or longtext: what to draw in place of the stored text (an option's label). */ shown?: string; + /** The value is the one already stored: a counter above the limit is then not an error (D90). */ + unchanged?: boolean; }; +/** The ids a control is described by: the error, and the counter while it shows. */ +function describe( + errorId: string | undefined, + counterId: string, + counter: unknown, +): string | undefined { + return [errorId, counter ? counterId : ""].filter(Boolean).join(" ") || undefined; +} + +/** `1 842 of 2 000 characters`: red once the text is longer than the field allows. + * + * The count itself is read with the field (`aria-describedby`) and changes with every key, so it + * is not a live region. A screen reader is told in words when the text gets close to the limit and + * when it goes over, once for each and never per key: the message is the same for every count in + * a band. The region is always there for a field with a limit, so the first change is announced. */ +function CharCounter({ + id, + state, + max, +}: { + id: string; + state: { used: number; max: number; over: boolean } | null; + /** The limit of the field, when it has one. */ + max: number | undefined; +}) { + if (max === undefined) return null; + return ( + <> + {state && ( + + {t("{used} of {max, plural, one {# character} other {# characters}}", { + used: fmt.number(state.used), + max: state.max, + })} + + )} + + {!state + ? "" + : state.over + ? t("Over the limit of {max, plural, one {# character} other {# characters}}", { max }) + : t("Close to the limit of {max, plural, one {# character} other {# characters}}", { + max, + })} + + + ); +} + /** The control for one field, chosen by its kind. */ export function FieldInput(p: InputProps) { const { field: f, value, onChange, readOnly } = p; const readText = readOnly && p.shown !== undefined ? p.shown : String(value); + // How full a text is (D90), shown only near the limit, and only where it can still be edited. + const counterId = `${useId()}-count`; + const editableText = !readOnly && (f.kind === "text" || f.kind === "longtext"); + const limited = editableText ? f.max_length : undefined; + const counter = editableText ? counterState(f, String(value)) : null; const common = { id: p.id, autoFocus: p.autoFocus, - "aria-invalid": p.invalid || undefined, + // Over the limit of the field and about to be sent: invalid before the save says so. A value + // left as stored is savable (D90 Decision 6), so it is not marked. + "aria-invalid": p.invalid || (counter?.over && !p.unchanged) || undefined, "aria-describedby": p.describedBy, "aria-required": f.required || undefined, onKeyDown: p.onKeyDown, @@ -191,13 +250,17 @@ export function FieldInput(p: InputProps) { ); case "longtext": return ( -