diff --git a/CLAUDE.md b/CLAUDE.md
index e9213f30..d722637b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,7 +2,7 @@
Open-source operating system for businesses: an Odoo-class modular suite (ERP, CRM, accounting, inventory, manufacturing, HR, projects, POS, e-commerce, website, marketing, helpdesk, documents) on one shared data model. Clean-room re-implementation of Odoo's concepts and behaviour, not a code port. Apache-2.0 and completely free to use and to self-host.
-The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), 2026-10-28 and 2026-10-29 (OR-35) are recorded in `docs/DECISIONS.md`, sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)". They are RATIFIED and win over every Proposed position in every document and in the research. Anything the owner has not ruled stays PROPOSED or open; do not treat it as decided.
+The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), 2026-10-28 and 2026-10-29 (OR-35 to OR-37) are recorded in `docs/DECISIONS.md`, sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)". They are RATIFIED and win over every Proposed position in every document and in the research. Anything the owner has not ruled stays PROPOSED or open; do not treat it as decided.
## Hard tech constraints (set by the owner)
@@ -18,6 +18,7 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34),
3. the mobile toolchain of React Native (Expo) apps (the owner said "with the mobile-toolchain exception"; these conditions are its recorded form): Metro, the Expo CLI, Gradle and Xcode cannot be run by Vite+, so they are started through `vp env exec` or `vp run` tasks. pnpm stays the only package manager, and `vp check`, `vp test` and `vp build` still cover every shared TypeScript package.
There are no other exceptions. In particular `pnpm licenses list` is not one (OR-9; Q33 is answered); read licence fields from the SBOM or go through `vp`.
+- **Native apps** (RATIFIED, OR-36; D87): every native app we build, the Mileage app included, works on both Android and iOS. They are React Native (Expo) apps (OR-34) built with `expo prebuild`; the iOS build runs in Xcode on a Mac (the owner has a Mac mini connected to the same network). The ruling gives no agent an Apple or Google account, a store listing or a publication (Q37 is unchanged).
- **Pin policy** (RATIFIED, D23, I-3, OR-8): "latest" means the newest release that has passed a cooldown of 7 to 14 days, or its `x.y.1` patch. The toolchain therefore intentionally runs up to about three weeks behind the newest release. Pins are exact; the pnpm major is 12.x (OR-28; `package.json` pins `pnpm@12.6.0`). Adopting Rust 1.99.x follows mechanically from this policy and needs no separate ruling. See `docs/DECISIONS.md` section 1.1.
## Licence, price and working rules (set by the owner, RATIFIED)
@@ -32,18 +33,18 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34),
## Layout
- `README.md` — entry point and document map. `LICENSE`, `NOTICE` — Apache-2.0 text and attribution (OR-1).
-- `docs/PRODUCT.md` — master product description. `docs/DECISIONS.md` — binding decision register (wins over the research where they differ); its sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" hold OR-1 to OR-35 and win over every Proposed position.
+- `docs/PRODUCT.md` — master product description. `docs/DECISIONS.md` — binding decision register (wins over the research where they differ); its sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" hold OR-1 to OR-37 and win over every Proposed position.
- `docs/CONVENTIONS.md` — naming and contract authority (identifiers, events, permission groups, data classes, ports); it ranks below `docs/DECISIONS.md` and above every other document.
- `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, `docs/BUSINESS.md`, `docs/LOCALIZATION.md`, `docs/ENGINEERING.md`, `docs/GLOSSARY.md`.
- `docs/spec/` — per-domain product specifications. `docs/spec/cli.md` is the agentic-first command surface: the `obop` CLI, its REST and MCP counterparts and the free plugin registry client (OR-3, OR-4); parts of it are built (the `obop` binary), the rest is PROPOSED.
- `docs/research/` — the fact-checked research corpus (index: `docs/research/README.md`). Its licensing, pricing, funding, team, support and open/paid recommendations are superseded by the owner rulings.
- Status: early code (the first target is Germany, OR-7). A Cargo workspace (`crates/kernel/*`, `crates/modules/*`, `crates/obop`) and a Vite+ backoffice (`web/apps/backoffice`) exist. Storage is SQLite locally and PostgreSQL for deployments (D44), via `obop-storage`; records are JSON documents until the schema manager exists.
-- Native apps (D87, OR-34; `docs/spec/mobile.md`): React Native (Expo) apps are allowed as obop clients (OR-34); the proposal limits them to first-party apps and to cases a PWA cannot serve (D87, Proposed). Expo SDK 57 for now; the app starts on SDK 58 if its packages pass the pin-policy cooldown at the start of its build (OR-34 e, amended 2026-10-28). The first is Mileage, planned at `web/apps/mileage-app` with `packages/mileage.toml`. Built so far (the Expo spike, `docs/spec/mobile.md` 8.3): `web/apps/mileage-app` (Expo SDK 57, one screen, no ios/ or android/ checked in) and `web/packages/trip-core` (pure TypeScript, `vp test`); the app is started through `vp run` scripts (`vp run start`, `vp run android:prebuild`, `vp run android:build`); the mileage backend is the built-in module `crates/modules/mileage` (trips and points, review, distance at `end`; spec 8.6; no `packages/mileage.toml`); the real app features are not built yet.
+- Native apps (D87, OR-34, OR-36; `docs/spec/mobile.md`): React Native (Expo) apps are allowed as obop clients (OR-34), and every native app works on both Android and iOS (OR-36); the proposal limits them to first-party apps and to cases a PWA cannot serve (D87, Proposed). Expo SDK 57 for now; the app starts on SDK 58 if its packages pass the pin-policy cooldown at the start of its build (OR-34 e, amended 2026-10-28). The first is Mileage, planned at `web/apps/mileage-app` with `packages/mileage.toml`. Built so far (the Expo spike, `docs/spec/mobile.md` 8.3): `web/apps/mileage-app` (Expo SDK 57, one screen, no ios/ or android/ checked in) and `web/packages/trip-core` (pure TypeScript, `vp test`); the app is started through `vp run` scripts (`vp run start`, `vp run android:prebuild`, `vp run android:build`); the mileage backend is the built-in module `crates/modules/mileage` (trips and points, review, distance at `end`; spec 8.6; no `packages/mileage.toml`); the real app features are not built yet.
- Code map: `obop-registry` (model/field/command metadata and the D41 install gate), `obop-commands` (the one `Dispatcher` behind REST, MCP and CLI: permissions, masking, approvals, generated get, list, search, create, update and delete, guards for governed state fields D48, server-side list queries D49), `obop-masking` (D40), `obop-http` (axum; `ops.rs`: `/healthz`, `/readyz`, `/metrics`, D67; `cors.rs`: validated `OBOP_ALLOWED_ORIGINS`, D68; `shell.rs`: the back office build, with `OBOP_DEFAULT_LANGUAGE` added as `` for the screens before sign-in, D52 amendment), `obop-mcp` (stdio), modules `base`, `product`, `crm`, `sales`, `invoicing`. A module declares models in the registry and gets all surfaces generated; every field needs a data class, permission group and description (`obop modules check`, or `obop modules check FILE...` for package files, no database needed). A select field that an action moves is governed (D48): `create` starts at its first option and `update` may only change it as an implicit run of a declared action, with that action's group and approval.
- 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.
+- Field limits (D90; merged as PR 33, tag `v0.2.1-r1`; the four numbers, the refusal above the default and the version effect are RATIFIED by OR-37, the rest is PROPOSED): `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 OR-37 a rules); 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5cd51e83..d733273d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -806,7 +806,7 @@ Rust owns contracts (D15): OpenAPI 3.1 from utoipa feeds generated TypeScript ty
### 10.5 Shells, offline, i18n, accessibility
-- **Shells.** PWA baseline (`vite-plugin-pwa`, Workbox), online-first; Tauri 2 plus a Rust hardware agent for POS and desktop (W3). **Native apps (OR-34, D87):** React Native (Expo) apps are allowed as obop clients of the same REST and OpenAPI surface (OR-34). The proposal limits them to first-party apps and to cases a PWA cannot serve (recording a trip while the phone is locked is the first case), and keeps Tauri mobile out (D87, Proposed); the first app is Mileage ([spec/mobile.md](spec/mobile.md)). They would sign in through the system browser and `obop-oauth` (the D64 amendment), keep a SQLite command outbox on the device (the D13 amendment) and be revocable as `device` records (all Proposed). Nothing depends on browser hardware APIs: Safari has no WebUSB, Web Serial or WebHID [R12 s3.4].
+- **Shells.** PWA baseline (`vite-plugin-pwa`, Workbox), online-first; Tauri 2 plus a Rust hardware agent for POS and desktop (W3). **Native apps (OR-34, D87):** React Native (Expo) apps are allowed as obop clients of the same REST and OpenAPI surface (OR-34), and every native app works on both Android and iOS (OR-36). The proposal limits them to first-party apps and to cases a PWA cannot serve (recording a trip while the phone is locked is the first case), and keeps Tauri mobile out (D87, Proposed); the first app is Mileage ([spec/mobile.md](spec/mobile.md)). They would sign in through the system browser and `obop-oauth` (the D64 amendment), keep a SQLite command outbox on the device (the D13 amendment) and be revocable as `device` records (all Proposed). Nothing depends on browser hardware APIs: Safari has no WebUSB, Web Serial or WebHID [R12 s3.4].
- **i18n.** Lingui, PO catalogs, an ICU MessageFormat 1 subset; PO parser and `icu` in Rust; Weblate; 12 target locales at first pack stable; machine translation is a suggestion and banned for statutory labels; RTL and CJK fixtures in CI (D34). Inputs accept `.` and `,` as decimal separators, key handling is IME-safe (`isComposing` plus a keyCode 229 guard), and totals use the shared Rust decimal crate compiled to Wasm so client and server agree (spike S4).
- **Accessibility.** WCAG 2.2 AA is a release gate: React Aria, Oxlint `jsx-a11y`, axe in Playwright, manual screen-reader passes on five flows, every drag with a non-drag path, remappable single-key shortcuts. Logical CSS only (CI bans physical margins); DTCG 2025.10 tokens compile to runtime CSS custom properties with `data-theme` and `data-density`, no per-theme bundles (D35).
- **Storefront** (Proposed, D14). Pages are versioned block JSON compiled into immutable snapshots, rendered in Rust with minijinja (also built to Wasm for editor preview) plus Vite islands hydrated through the Vite manifest; cart, account and checkout are uncached islands; checkout uses hosted payment fields, a script allow-list and CSP [R12 s7]. No Node in production; Astro, Leptos or Dioxus would each need an explicit owner waiver of the Vite+ rule (D14; Q14).
diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md
index 91b7a00c..020a52d6 100644
--- a/docs/CONVENTIONS.md
+++ b/docs/CONVENTIONS.md
@@ -49,7 +49,7 @@ Reading notes. "Adopted" means this file fixes the spelling or contract for ever
| Account role | Lower snake_case; registry in finance 4.1.1 | `stock_valuation`, `grni`, `cogs` |
| Problem code | Lower snake_case RFC 9457 `type` slug. The CLI prints it unchanged as `error.code` and derives a coarse exit code from it (2.4) | `idempotency_key_reuse`, `field_not_readable`, `plan_stale` |
| Setting, config | Setting key `.`; database setting `obop.company_ids`; manifest keys as ARCHITECTURE 9.3; environment variables `OBOP__` with `_FILE` for secret files (Proposed). The client areas `CLI` and `AUTH` are listed in 2.4. If the hosted sandbox (D83) needs its own settings, the Proposed reservation is the server area `OBOP_SANDBOX_` (for example a session time to live) and a client profile named `sandbox`; no other document uses either yet (section 9.1) | `stock.lock_date`, `obop.company_ids` |
-| Decision ids | As the identifier-namespace table of [DECISIONS.md](DECISIONS.md) section 4: `D1` to `D89`, `Q1` to `Q61`, constraints `F1` to `F6`, and owner rulings `OR-1` to `OR-35` (written `OR-4`, never "ruling 4"; a ruled statement cites its OR number, a Proposed one "Proposed (Dn)") | A family that shares letters with another is cited with its document or qualifier ("UI tier F2", "ENGINEERING Q-E2", "finance open question 14", "retail RP11"). Q18 (keep and qualify, or rename) is open; this file keeps and qualifies |
+| Decision ids | As the identifier-namespace table of [DECISIONS.md](DECISIONS.md) section 4: `D1` to `D90`, `Q1` to `Q61`, constraints `F1` to `F6`, and owner rulings `OR-1` to `OR-37` (written `OR-4`, never "ruling 4"; a ruled statement cites its OR number, a Proposed one "Proposed (Dn)") | A family that shares letters with another is cited with its document or qualifier ("UI tier F2", "ENGINEERING Q-E2", "finance open question 14", "retail RP11"). Q18 (keep and qualify, or rename) is open; this file keeps and qualifies |
### 2.2 Vocabulary and spelling
@@ -519,7 +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 |
+| The spellings that D90 proposes (built and merged, PR 33, tag `v0.2.1-r1`; the spellings stay Proposed): 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 RATIFIED by OR-37 a | 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 25f13ebb..7e73e6f0 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -1,6 +1,6 @@
# obop decision register
-**Status:** Draft 0.1. The owner rulings OR-1 to OR-21 of 2026-10-01 (OR-1 to OR-13 first, OR-14 to OR-21 later the same day), OR-22 to OR-34 of 2026-10-27 and OR-35 of 2026-10-28 and 2026-10-29 (parts a to f and g to k) are recorded below as RATIFIED and override every Proposed position in this register, the companion documents and the research; everything the owner has not ruled still awaits ratification. **As of:** 2026-10-01, called month 0 (M0); the rulings of 2026-10-27 to 2026-10-29, the records D40 to D79 (the build) and the records D88 and D89 (designs of 2026-10-28, nothing built) are recorded as of their own dates. **Scope:** product and architecture. Nothing here is legal advice; items marked "counsel" need a lawyer.
+**Status:** Draft 0.1. The owner rulings OR-1 to OR-21 of 2026-10-01 (OR-1 to OR-13 first, OR-14 to OR-21 later the same day), OR-22 to OR-34 of 2026-10-27, OR-35 of 2026-10-28 and 2026-10-29 (parts a to f and g to k) and OR-36 and OR-37 of 2026-10-29 are recorded below as RATIFIED and override every Proposed position in this register, the companion documents and the research; everything the owner has not ruled still awaits ratification. **As of:** 2026-10-01, called month 0 (M0); the rulings of 2026-10-27 to 2026-10-29, the records D40 to D79 (the build) and the records D88 and D89 (designs of 2026-10-28, nothing built) are recorded as of their own dates. **Scope:** product and architecture. Nothing here is legal advice; items marked "counsel" need a lawyer.
## 0. Front matter and how to read
@@ -9,7 +9,7 @@ This register is authoritative for every other obop document. Where a research n
| Status | Meaning | Rule for writers |
|---|---|---|
| FIXED | Owner-mandated constraint | Never changes without the owner |
-| RATIFIED | Owner ruling of 2026-10-01, 2026-10-27, 2026-10-28 or 2026-10-29 (OR-1 to OR-35), recorded as decided; it is binding in effect and keeps this label so the origin stays visible | State as fact and cite the OR number; a later owner ruling may change it, nothing else |
+| RATIFIED | Owner ruling of 2026-10-01, 2026-10-27, 2026-10-28 or 2026-10-29 (OR-1 to OR-37), recorded as decided; it is binding in effect and keeps this label so the origin stays visible | State as fact and cite the OR number; a later owner ruling may change it, nothing else |
| BINDING | Research-resolved technical decision | State as fact; changing it needs a new ADR (next free D-ID; the old record is marked "Superseded by") |
| PROPOSED | Needs explicit owner ratification | Write "Proposed (Dn)" wherever you rely on it |
| DEFERRED | Decide later, with a trigger | Do not design around a guess; cite the trigger |
@@ -70,7 +70,7 @@ The OR numbers, F5, F6, I-1 to I-4, CN-numbers and the ids of the other document
-**Status: RATIFIED.** The owner gave these rulings in chat on 2026-10-27, answering questions the lead asked directly. Quotation marks are the owner's own words. They continue the numbering of the 2026-10-01 rulings (OR-1 to OR-21 of that day, recorded above; OR-22 was the next free number). OR-22 to OR-33 were given in answer to the lead; OR-34 was given to the main session and relayed to the lead session the same day, as its row says. OR-35 was given on 2026-10-28 and 2026-10-29 and relayed by the setup session (parts (a) to (f) of 2026-10-28 in two messages, parts (g) to (k) of 2026-10-29); it continues this table, and its row says what was and was not quoted.
+**Status: RATIFIED.** The owner gave these rulings in chat on 2026-10-27, answering questions the lead asked directly. Quotation marks are the owner's own words. They continue the numbering of the 2026-10-01 rulings (OR-1 to OR-21 of that day, recorded above; OR-22 was the next free number). OR-22 to OR-33 were given in answer to the lead; OR-34 was given to the main session and relayed to the lead session the same day, as its row says. OR-35 was given on 2026-10-28 and 2026-10-29 and relayed by the setup session (parts (a) to (f) of 2026-10-28 in two messages, parts (g) to (k) of 2026-10-29); it continues this table, and its row says what was and was not quoted. OR-36 and OR-37 were given on 2026-10-29 and continue it: OR-36 is the owner's own words to the lead session (quoted), and OR-37 is the owner's choice of an option for each of two questions the lead put to him (not quoted); the setup session relayed the number OR-36 and the two answers of OR-37, and each row says how it was given.
| OR | Ruling | Decisions and questions touched | Effect |
|---|---|---|---|
@@ -84,10 +84,12 @@ The OR numbers, F5, F6, I-1 to I-4, CN-numbers and the ids of the other document
| OR-29 | **Runner plan (Q17): GitHub-hosted runners.** Owner: withdraw the Hetzner CCX43 proposal; CI stays on GitHub-hosted runners (`ubuntu-latest`, free for a public repository). | D36; Q17 (closed), ENGINEERING Q-E1 | Moving CI off GitHub-hosted runners needs a new owner decision. The cost table of ENGINEERING 3.2 stays as research. |
| OR-30 | **DCO CI job (D82): not yet.** Owner: not now; build it when outside contributors appear. | D82 | `.github/workflows/ci.yml` has no DCO job and the documents say so. The sign-off rule itself (OR-1, OR-18) stands: the owner signs off every commit. |
| OR-31 | **CLI grammar freeze (Q22): before the 0.3 public alpha.** Owner: freeze before the 0.3 public alpha; today's built spellings keep working as aliases. | D80; Q22 | The freeze point is settled (2027-04-06 in the current calendar). Which spelling is canonical, and the rest of the grammar of [spec/cli.md](spec/cli.md), stay Proposed under Q22. |
-| OR-32 | **Release labels.** Owner: the calendar's 0.1, 0.2 and 0.5 are product milestones (their labels are Proposed); the code's `0.2.0` core version and `v0.2.0-rN` tags are the running build number until the first milestone is announced. | D26, D55; Q8 | Nothing in the code is renamed. The dates of the milestones stay Proposed under Q8. |
+| OR-32 | **Release labels.** Owner: the calendar's 0.1, 0.2 and 0.5 are product milestones (their labels are Proposed); the code's `0.2.0` core version and `v0.2.0-rN` tags are the running build number until the first milestone is announced. | D26, D55; Q8 | Nothing in the code is renamed. The dates of the milestones stay Proposed under Q8. Conforming clause (D88 Decision 14; it is not the owner's words and changes none of them): from the first patch bump the build number is 0.2.N, which is 0.2.1 since the merge of PR 33 (tag `v0.2.1-r1`, 2026-10-29). |
| OR-33 | **README.** Owner: keep the short README of this landing. | README.md | The sections of the old README (the name, the reader table, the source list) stay out; the document map and DECISIONS carry them. |
| OR-34 | **React Native (Expo) apps, the mobile-toolchain exception, manual trip recording.** Owner: "React Native (Expo) allowed as an obop client with the mobile-toolchain exception; manual start and stop as the baseline, auto-stop only after legal sign-off; Transistorsoft excluded as the default; Expo SDK 57 for now." The owner gave these words in chat to the main session, and the main session relayed them to the lead session on 2026-10-27. The main session also relayed, not as a quotation, that the owner asked for the decisions to be recorded and the first slice to start. In parts: (a) React Native (Expo) apps are allowed as obop clients, that is as clients of the same API as the web back office. D87 proposes to limit this to first-party apps and to cases a PWA cannot serve; that limit came with the proposal, is not in the owner's words and is Proposed. (b) The mobile-toolchain exception to "`vp` only": the owner said "with the mobile-toolchain exception". It is a third named exception next to the two of OR-9 (the nightly exit drill; `pnpm sbom`). The conditions are the exception as proposed and recorded here: Metro, the Expo CLI, Gradle and Xcode, which `vp` cannot run, are started through `vp env exec` or `vp run` tasks; pnpm stays the only package manager; `vp check`, `vp test` and `vp build` still cover every shared TypeScript package. (c) Manual start and stop is the baseline for recording a trip; auto-stop (an automatic trip end) only after legal sign-off. The proposal adds product and works-council sign-off, and D87 keeps all three as the condition. (d) Transistorsoft is excluded as the default (the owner's words). The proposal's additions are Proposed: the library is react-native-background-geolocation, its release builds need a paid licence key (read on the vendor's README by the lead session on 2026-10-27; the price is not on that page), the reason for the exclusion is that the software must be completely free to build and self-host (OR-1, OR-2), and an optional adapter later is not forbidden. (e) Expo SDK 57 (React Native 0.86) for now. **Amended 2026-10-28** (the owner's answer to the lead's question about the move, relayed by the setup session; not a quotation): start the app on SDK 58 (React Native 0.88) if its packages pass the 7 day cooldown of D23 when the app's build starts (step 5 of the Mileage slice); otherwise stay on SDK 57 and move at the first clean point. This amends "for now"; the move is no longer left open. The pin stays SDK 57 until that step starts; the app is not built on SDK 58 yet. (f) Accessibility on a real device (2026-10-28, same relay): a human TalkBack pass is not scheduled now and is not a required step of the real-phone test; the accessibility checklist stays in docs/spec/mobile.md, and until the pass is done the app's accessibility is unchecked on a real device. (g) Where the app's components are shown (Q42, 2026-10-28, relayed by the setup session as Pavel's answer; not a quotation): the normal web Storybook, with the app's components drawn through react-native-web (option 1). The stated downside is part of the ruling: touch, safe areas, fonts and TalkBack are not shown there and are checked on the phone in the acceptance walk. A hidden gallery screen in the app (option 3) only if the web Storybook and the phone disagree; Storybook running inside the app on a device (option 2) only if native-only components dominate, past about 15. Neither option was tested in this repository when it was ruled: the first step of the app's build checks that react-native-web renders the five components (Screen, Button, Row, Field, Banner), and if it fails the question goes back to the owner. | D87, D13, D64, D23, D15, D37; OR-1, OR-2, OR-9; Q37 to Q42 | D87 records the decision. Ratified here: the owner's words in parts (a) to (e); the details the proposal added to them are Proposed. Proposed until built: the scope limits (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, visible recording, the `expo-location` default path and its fallback, the reason given for excluding Transistorsoft and the remark that an optional adapter is not forbidden, 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) and the answers to Q37 to Q41 and Q43 (Q42 is answered by part g). The non-goal "native store apps beyond the PWA and Tauri shells" is replaced by the owner's "allowed as an obop client"; the limits the proposal adds to it are Proposed (PRODUCT 11.3, ux 8, people 8; ARCHITECTURE 10.5 gains the native shell). OR-9's "exactly two" exceptions become three named exceptions, OR-9's two and the mobile toolchain; CLAUDE.md and every place that said "exactly two" are conformed, and no other exception exists. At the time of the ruling nothing was built (since then only the Expo spike of the first slice exists, docs/spec/mobile.md 8.3): the main session relayed on 2026-10-27 that the owner asked for the first slice to start (the Mileage package and app, Android first, development builds, synthetic data only); that request is relayed, not quoted. The ruling gives no agent an Apple or Google account, a store listing or a publication; whether those stay the owner's alone is Proposed (Q37). Auto-stop stays out of every build until the sign-off exists (Q41). |
-| OR-35 | **Delete rules on references (D89) and the closed poll (D88): the leaks that remain, the approval of an agent's delete, the interim audit sink, the cap, who may delete before the DELETE position, and a closed poll's result (2026-10-28); the approver view, the version amendment of MODULES.md 3.1, the gloss of the relay words, API fields and field descriptions in patch releases, and the generated texts that tell agents what each operation does in patch releases, limited to the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions (2026-10-29).** Relayed by the setup session. Parts (a) to (f) are of 2026-10-28, in two messages (parts (a) to (c) first, parts (d) to (f) later the same day); parts (g) to (k) are of 2026-10-29, with a later confirmation of the question that part (h) answers; part (j) is a second question put to the owner the same day, and part (k) a further question put to the owner that day after part (j). The owner's own words were not passed on, so nothing in this row is a quotation. In parts: (a) The owner accepts that a `refuse` rule keeps a one-bit signal that something blocks a delete, and that the row cap of an atomic delete is an exact-count channel for a caller who can add visible dependents (D89 Decision 9). For models whose rows are private to the people who made them, the answer is to declare `cascade` or `set_null`. (b) Approving an agent's delete also approves the cascade that the declared rules produce at that moment, and only with a human approver; an agent never approves its own cascade. The approver sees the static list of declared cascade edges and not the exact rows that the delete hits at that moment (D89 Decision 5). (c) Until the D75 history is switched on for the child models, a cascade writes chunked `security_event` entries with the ids it touched (D89 Decision 8). (d) The audience that can read one all-company post is about 500 or fewer. The cap of one atomic delete therefore stays at 2,000 touched rows with a ceiling of 4,000, and the bench measures 2,000 and 4,000 (D89 Decision 7). The route for erasing a person (delete or anonymise) is not decided by this answer; the relay words it as staying with `D11/6.3/DPO` (glossed in the effect cell). (e) `cascade` ships before the per-role DELETE permission of D79 exists, and a public target gets a plan advice (D89 Decisions 2 and 12, D89-advice-1). (f) A closed poll's result is not final for now: delete stays open under frozen rules, so D88 does not cover delete, and no erasure route is required in the same release (D88 Decision 4 and slice 3). (g) The approver view that D89 slice 2 builds for an agent's delete hides an edge into a model the approver cannot read, as the design of D89 Decision 5 has it: such an edge is not shown and not counted. The alternative that was put to the owner, showing every declared edge by model name, is not chosen. (h) The first question put to the owner was whether a patch release of the core may add optional manifest keys, values, functions and routes, and may change the text or the status of a refusal where a decision record and the release notes list it; the alternative was a 0.3.0 bump, which would make all 23 bundled packages incompatible. The owner accepted it, which is the amendment of MODULES.md 3.1 as to these items (D88 Decision 14, D89 Decision 15; the record reads values as values of an existing key and functions as functions of the expression language), and the 23 bundled packages are not moved to 0.3.0: they keep `requires_core = "^0.2"`. The owner was not asked about patch numbering, the `^0.2.N` spelling, the tag form or the test names. (i) The gloss of the relay words `D11/6.3/DPO` in part (d) and in Q59 is confirmed: `D11` is the platform's D11, `6.3` is the platform's ask 6.3 (retention and erasure), `DPO` is the data protection officer. (j) A second question, put to the owner on 2026-10-29 after part (h) and separate from it: whether a patch release may also add API fields and change descriptions (descriptions meaning field descriptions in the MCP and OpenAPI documents). The owner accepted both, again with no 0.3.0 bump. The key `on_delete` in `/api/meta` and its field-description sentence (D89), and the other API fields and field descriptions listed in D88 Decision 14 and D89 Decision 15, rest on it. (k) A further question, put to the owner on 2026-10-29 after part (j) and separate from it: whether a patch release may also change the generated texts that tell agents what each operation does, meaning the descriptions of the create, update and bulk_create tools, of every delete, and of security_event. The owner answered yes and asked that they be included. The relay names no safeguard and does not mention a 0.3.0 bump; the question concerned a patch release, which is how the yes is read. The condition that a decision record lists the change as a visible difference and the release notes list it is the lead's own, applied to these texts by analogy to part (h) (part (j) has no such condition); the owner was not shown it for part (k) and can correct it. The relay speaks of the descriptions of security_event wholesale, so part (k) covers all of them as relayed: the model description and the descriptions of the fields `kind` and `detail` that D89 Decision 8 lists (the descriptions of those two fields are also field descriptions within part (j)). The reworded generated descriptions that D88 Decision 12 lists for `create`, `update` and `bulk_create` of a model with rules, and that D89 Decisions 8 and 13 list for every `.delete` and for `security_event`, rest on it. Part (j) is not widened by part (k): (j) means field descriptions in the MCP and OpenAPI documents, which is what the owner was shown for (j), and (k) is a separate, later question that names these texts and no other generated text. | D88, D89, MODULES.md 3.1; Q59, Q60 and Q61 are closed by parts (d) to (f) | Parts (a) to (e) and (g) are RATIFIED in D89, part (f) in D88, parts (h), (j) and (k) in D88 Decision 14, D89 Decision 15 and MODULES.md 3.1, and part (i) in this row and in Q59, for exactly the points they name; nothing else in either record is ruled and nothing is built. (a): Decision 9. (b): the human-approver rule of Decision 5. The ruling says that the approver sees the static list of declared cascade edges and not the exact rows. It presumes a view that shows that list; no such view exists at r31, and D89 slice 2 builds one (Decision 5; its route shape, card layout and D80 parity are PROPOSED). Part (b) did not say what the list holds for an edge into a model the approver cannot read; part (g) of 2026-10-29 does: such an edge is not shown and not counted, so an approver can approve a cascade into models they cannot see, and the human approval stays the control. (c): the interim use of `security_event` with the touched ids (Decision 8); the kind name, the chunk size and the entry format are PROPOSED. (d): the audience figure, the starting cap and ceiling and the bench at both (Decision 7); it does not say that the numbers stay if the bench contradicts them (the record reads that as a new question for the owner), and it does not remove the exact-count channel, which exists at any finite cap. (e): that `cascade` may ship before D79's DELETE position exists (Decisions 2 and 12); until D79 is built, the record's term `someone who may delete it` (from ask 11.6) means the write group plus visibility. (f): that delete stays outside D88 for now (Decision 4, slice 3). (g): the hiding of edges into unreadable models in the approver view (Decision 5), and nothing else of the view. (h), (j) and (k): the amendment of MODULES.md 3.1 as it reads there (the items of part (h); then API fields and descriptions of part (j), where descriptions are, as relayed, field descriptions in the MCP and OpenAPI documents; then the generated texts of part (k), which are, as relayed, the descriptions of the create, update and bulk_create tools, of every delete and of security_event, those that D88 Decision 12 and D89 Decisions 8 and 13 list), and that the bundled packages keep `^0.2`. One thing in that text is not ruled by the owner: the condition that a decision record lists the change as a visible difference and the release notes list it, as it applies to the texts of part (k). It is the lead's decision of 2026-10-29, applied by analogy to part (h), it stays PROPOSED, and the owner can correct it (part (k)). Part (k) names no other generated text: it does not by itself reach dialog lines, labels or other generated texts, and the text or status of a refusal stays under part (h). None of these three parts (h, j and k) rules which commit takes which patch number, the `^0.2.N` spelling for a package that uses a key added by a patch release, the tag form `v0.2.N-rM` or the test names, which stay the lead's design (D88 Decision 14) and PROPOSED. (i): the reading of `D11/6.3/DPO`. Glosses and platform statements (the gloss of `D11/6.3/DPO` is confirmed by part (i); the platform statements are not part of any ruling): in (d) the relay's `D11` is a label of the platform side (the platform session's own state note lists a D11 among its carried items) and is not obop's D11 (the extension and scripting model), `6.3` is the platform's ask 6.3 (erasure and retention; it is also the last step of the lead's build order in ROADMAP section 3.4) and `DPO` is read as the data protection officer, so (d) says that the erasure route stays with the platform's erasure and retention work and its data protection officer; the owner confirmed this reading on 2026-10-29 (part (i)), and it stays here, outside the text of part (d). Platform note (the platform team's statements, not the owner's): erasing a person from closed polls means deleting their votes (its answer to Q58), and a withdrawal after the close remains a known gap on its side. The phrase for now in (f) means, on the record's reading and not in the answer, that the owner can be asked again. Lead decisions that are not owner rulings and stay PROPOSED, counted after parts (g), (h) and (k): for D88, three (the reuse of `assertion_failed` with `assert_key` and the additive `assert_scope`, and the mechanism of the version rule, both of 2026-10-28; and the listing condition for the texts of part (k), of 2026-10-29); for D89, three (the status 409 `delete_blocked` in slice 0b, and the same mechanism, both of 2026-10-28; and the same listing condition). The approval view of slice 2, counted before as a lead decision of D89 (then the third of that count), is no longer counted: OR-35 b presumes it and part (g) ruled its one open point. The mechanism of the version rule (D88 Decision 14): each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line (four in all, two for D88 and two for D89), a package that uses what a patch release added writes `^0.2.N`, and the tag form and the test names follow; parts (h), (j) and (k) rule the amendment of MODULES.md 3.1 and that the bundled packages keep `^0.2`, and nothing else of it (the listing condition for the texts of part (k) is not among what they rule). |
+| OR-35 | **Delete rules on references (D89) and the closed poll (D88): the leaks that remain, the approval of an agent's delete, the interim audit sink, the cap, who may delete before the DELETE position, and a closed poll's result (2026-10-28); the approver view, the version amendment of MODULES.md 3.1, the gloss of the relay words, API fields and field descriptions in patch releases, and the generated texts that tell agents what each operation does in patch releases, limited to the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions (2026-10-29).** Relayed by the setup session. Parts (a) to (f) are of 2026-10-28, in two messages (parts (a) to (c) first, parts (d) to (f) later the same day); parts (g) to (k) are of 2026-10-29, with a later confirmation of the question that part (h) answers; part (j) is a second question put to the owner the same day, and part (k) a further question put to the owner that day after part (j). The owner's own words were not passed on, so nothing in this row is a quotation. In parts: (a) The owner accepts that a `refuse` rule keeps a one-bit signal that something blocks a delete, and that the row cap of an atomic delete is an exact-count channel for a caller who can add visible dependents (D89 Decision 9). For models whose rows are private to the people who made them, the answer is to declare `cascade` or `set_null`. (b) Approving an agent's delete also approves the cascade that the declared rules produce at that moment, and only with a human approver; an agent never approves its own cascade. The approver sees the static list of declared cascade edges and not the exact rows that the delete hits at that moment (D89 Decision 5). (c) Until the D75 history is switched on for the child models, a cascade writes chunked `security_event` entries with the ids it touched (D89 Decision 8). (d) The audience that can read one all-company post is about 500 or fewer. The cap of one atomic delete therefore stays at 2,000 touched rows with a ceiling of 4,000, and the bench measures 2,000 and 4,000 (D89 Decision 7). The route for erasing a person (delete or anonymise) is not decided by this answer; the relay words it as staying with `D11/6.3/DPO` (glossed in the effect cell). (e) `cascade` ships before the per-role DELETE permission of D79 exists, and a public target gets a plan advice (D89 Decisions 2 and 12, D89-advice-1). (f) A closed poll's result is not final for now: delete stays open under frozen rules, so D88 does not cover delete, and no erasure route is required in the same release (D88 Decision 4 and slice 3). (g) The approver view that D89 slice 2 builds for an agent's delete hides an edge into a model the approver cannot read, as the design of D89 Decision 5 has it: such an edge is not shown and not counted. The alternative that was put to the owner, showing every declared edge by model name, is not chosen. (h) The first question put to the owner was whether a patch release of the core may add optional manifest keys, values, functions and routes, and may change the text or the status of a refusal where a decision record and the release notes list it; the alternative was a 0.3.0 bump, which would make all 23 bundled packages incompatible. The owner accepted it, which is the amendment of MODULES.md 3.1 as to these items (D88 Decision 14, D89 Decision 15; the record reads values as values of an existing key and functions as functions of the expression language), and the 23 bundled packages are not moved to 0.3.0: they keep `requires_core = "^0.2"`. The owner was not asked about patch numbering, the `^0.2.N` spelling, the tag form or the test names. (i) The gloss of the relay words `D11/6.3/DPO` in part (d) and in Q59 is confirmed: `D11` is the platform's D11, `6.3` is the platform's ask 6.3 (retention and erasure), `DPO` is the data protection officer. (j) A second question, put to the owner on 2026-10-29 after part (h) and separate from it: whether a patch release may also add API fields and change descriptions (descriptions meaning field descriptions in the MCP and OpenAPI documents). The owner accepted both, again with no 0.3.0 bump. The key `on_delete` in `/api/meta` and its field-description sentence (D89), and the other API fields and field descriptions listed in D88 Decision 14 and D89 Decision 15, rest on it. (k) A further question, put to the owner on 2026-10-29 after part (j) and separate from it: whether a patch release may also change the generated texts that tell agents what each operation does, meaning the descriptions of the create, update and bulk_create tools, of every delete, and of security_event. The owner answered yes and asked that they be included. The relay names no safeguard and does not mention a 0.3.0 bump; the question concerned a patch release, which is how the yes is read. The condition that a decision record lists the change as a visible difference and the release notes list it is the lead's own, applied to these texts by analogy to part (h) (part (j) has no such condition); the owner was not shown it for part (k) and can correct it. The relay speaks of the descriptions of security_event wholesale, so part (k) covers all of them as relayed: the model description and the descriptions of the fields `kind` and `detail` that D89 Decision 8 lists (the descriptions of those two fields are also field descriptions within part (j)). The reworded generated descriptions that D88 Decision 12 lists for `create`, `update` and `bulk_create` of a model with rules, and that D89 Decisions 8 and 13 list for every `.delete` and for `security_event`, rest on it. Part (j) is not widened by part (k): (j) means field descriptions in the MCP and OpenAPI documents, which is what the owner was shown for (j), and (k) is a separate, later question that names these texts and no other generated text. | D88, D89, MODULES.md 3.1; Q59, Q60 and Q61 are closed by parts (d) to (f) | Parts (a) to (e) and (g) are RATIFIED in D89, part (f) in D88, parts (h), (j) and (k) in D88 Decision 14, D89 Decision 15 and MODULES.md 3.1, and part (i) in this row and in Q59, for exactly the points they name; nothing else in either record is ruled and nothing is built. (a): Decision 9. (b): the human-approver rule of Decision 5. The ruling says that the approver sees the static list of declared cascade edges and not the exact rows. It presumes a view that shows that list; no such view exists at r31, and D89 slice 2 builds one (Decision 5; its route shape, card layout and D80 parity are PROPOSED). Part (b) did not say what the list holds for an edge into a model the approver cannot read; part (g) of 2026-10-29 does: such an edge is not shown and not counted, so an approver can approve a cascade into models they cannot see, and the human approval stays the control. (c): the interim use of `security_event` with the touched ids (Decision 8); the kind name, the chunk size and the entry format are PROPOSED. (d): the audience figure, the starting cap and ceiling and the bench at both (Decision 7); it does not say that the numbers stay if the bench contradicts them (the record reads that as a new question for the owner), and it does not remove the exact-count channel, which exists at any finite cap. (e): that `cascade` may ship before D79's DELETE position exists (Decisions 2 and 12); until D79 is built, the record's term `someone who may delete it` (from ask 11.6) means the write group plus visibility. (f): that delete stays outside D88 for now (Decision 4, slice 3). (g): the hiding of edges into unreadable models in the approver view (Decision 5), and nothing else of the view. (h), (j) and (k): the amendment of MODULES.md 3.1 as it reads there (the items of part (h); then API fields and descriptions of part (j), where descriptions are, as relayed, field descriptions in the MCP and OpenAPI documents; then the generated texts of part (k), which are, as relayed, the descriptions of the create, update and bulk_create tools, of every delete and of security_event, those that D88 Decision 12 and D89 Decisions 8 and 13 list), and that the bundled packages keep `^0.2`. One thing in that text is not ruled by the owner: the condition that a decision record lists the change as a visible difference and the release notes list it, as it applies to the texts of part (k). It is the lead's decision of 2026-10-29, applied by analogy to part (h), it stays PROPOSED, and the owner can correct it (part (k)). Part (k) names no other generated text: it does not by itself reach dialog lines, labels or other generated texts, and the text or status of a refusal stays under part (h). None of these three parts (h, j and k) rules which commit takes which patch number, the `^0.2.N` spelling for a package that uses a key added by a patch release, the tag form `v0.2.N-rM` or the test names, which stay the lead's design (D88 Decision 14) and PROPOSED. (OR-37 b, a later question of the same day and separate from these three parts, accepted a patch per commit that widens the manifest and the tag form `v0.2.N-rM` for the release of the field limits; no other part of the mechanism was put to the owner.) (i): the reading of `D11/6.3/DPO`. Glosses and platform statements (the gloss of `D11/6.3/DPO` is confirmed by part (i); the platform statements are not part of any ruling): in (d) the relay's `D11` is a label of the platform side (the platform session's own state note lists a D11 among its carried items) and is not obop's D11 (the extension and scripting model), `6.3` is the platform's ask 6.3 (erasure and retention; it is also the last step of the lead's build order in ROADMAP section 3.4) and `DPO` is read as the data protection officer, so (d) says that the erasure route stays with the platform's erasure and retention work and its data protection officer; the owner confirmed this reading on 2026-10-29 (part (i)), and it stays here, outside the text of part (d). Platform note (the platform team's statements, not the owner's): erasing a person from closed polls means deleting their votes (its answer to Q58), and a withdrawal after the close remains a known gap on its side. The phrase for now in (f) means, on the record's reading and not in the answer, that the owner can be asked again. Lead decisions that are not owner rulings and stay PROPOSED, counted after parts (g), (h) and (k): for D88, three (the reuse of `assertion_failed` with `assert_key` and the additive `assert_scope`, and the mechanism of the version rule, both of 2026-10-28; and the listing condition for the texts of part (k), of 2026-10-29); for D89, three (the status 409 `delete_blocked` in slice 0b, and the same mechanism, both of 2026-10-28; and the same listing condition). The approval view of slice 2, counted before as a lead decision of D89 (then the third of that count), is no longer counted: OR-35 b presumes it and part (g) ruled its one open point. The mechanism of the version rule (D88 Decision 14): each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line (four in all, two for D88 and two for D89), a package that uses what a patch release added writes `^0.2.N`, and the tag form and the test names follow; parts (h), (j) and (k) rule the amendment of MODULES.md 3.1 and that the bundled packages keep `^0.2`, and nothing else of it (the listing condition for the texts of part (k) is not among what they rule). The counts of lead decisions above are not changed by OR-37 b, which accepts two parts of the mechanism (a patch per commit that widens the manifest, and the tag form `v0.2.N-rM`) for the release of the field limits only. |
+| OR-36 | **Every native app works on both Android and iOS; iOS is no longer deferred (2026-10-29).** Owner, in chat to the lead session: "the mielage app.. or any native app we do, like this one, should work on both android and ios. I have a mac mini connected to the same netwrok we can work with talk to the main session ask questions". The setup session relayed the number OR-36. The ruling is the first sentence of the quotation: every native app we build, the Mileage app included, works on both Android and iOS. The rest of his message says that a Mac mini is connected to the same network and can be worked with, and to talk to the main session and ask questions; those words are quoted above and are not interpreted further here. | D87, OR-34; Q37 | Ratified: native apps (D87) work on Android and iOS together, and the Mileage app is the first. iOS is no longer deferred. The sentence of D87 that Android comes first and iOS second, when a Mac is available, and the statement of ROADMAP 3.4 that the first slice is Android first, are replaced; no date is promised. The "Android first" in the OR-34 row stays as the record of what the main session relayed on 2026-10-27. An iOS build uses `expo prebuild` and Xcode on a Mac (Xcode runs on macOS only; general knowledge); Xcode is already named by the mobile-toolchain exception of OR-34 b, so the exception needs no change. The ruling does not change OR-34 otherwise: the scope limits of D87 stay Proposed, and it gives no agent an Apple account, a store listing or a publication (whether those stay the owner's alone is still Proposed, Q37). What D87 says is not delivered stays true until it is delivered, among it that nothing has been tried on iOS; [spec/mobile.md](spec/mobile.md) is edited by the iOS work and not by this record. |
+| OR-37 | **Field limits (D90): the default and the largest length of a text field, the refusal above the default, the version effect, and the merge of PR 33 (2026-10-29).** The lead put two questions to the owner on 2026-10-29. He chose the recommended option of each directly in the app, and the setup session relayed the same two answers. They are choices of options and not his own words, so nothing in this row is a quotation. In parts: (a) The numbers and the refusal. The default maximum length of a field that declares none is 2,048 characters for `text` and 100,000 for `longtext`, and the hard caps are 10,000 and 1,000,000. Writes above the default are refused for every existing package; stored rows and reads are never touched. The question said no more than this; that a package raises the maximum with `max_length` on the field up to the cap, and that a built-in field that declares nothing is under the default too, is how D90 Decision 3 builds it and is the lead's design. (b) The version effect. The new optional manifest keys (`max_length`, `min_length`, `min` and `max`) are a patch release under OR-35 h. `CORE_VERSION` goes from 0.2.0 to 0.2.1. A package that uses the new keys declares `requires_core = "^0.2.1"`, and the 23 bundled packages stay `^0.2`. The next code tag is `v0.2.1-r1`. The mechanism as it was put to him is accepted: a patch per commit that widens what the manifest accepts, and the tag form `v0.2.N-rM`. That mechanism and tag form were the lead's proposal (D88 Decision 14); no other part of the version mechanism was put to him. (c) The owner approved the merge of PR 33: it was merged on 2026-10-29 as the squash commit 32a0758 on main and tagged `v0.2.1-r1`. | D90, D88 Decision 14, D89 Decision 15, MODULES.md 3.1; OR-35 h | Ratified for exactly (a) and (b); nothing else of D90 is ruled. (a): the four constants of D90 Decision 3 (`DEFAULT_TEXT_MAX`, `DEFAULT_LONGTEXT_MAX`, `TEXT_MAX_CAP` and `LONGTEXT_MAX_CAP` in `obop-registry` `limits.rs`) and the refusal of Decision 9 (a). (b): the version effect of Decision 9, so far as it names the release of these keys and no other. Together they settle the two points that D90 raised for the owner (the numbers; the version effect and the refusal where there was none) so far as Decision 9 (a) goes; the visible differences (c), (d) and (e) of Decision 9 are named neither by OR-37 nor by OR-35 h, stay listed as visible differences in the release notes, and the record does not claim that a ruling covers them. The key names, the error code `limit_violated`, the rest of D90 and the lead decisions of the review of PR 33 are the lead's design and stay PROPOSED. No question is added to section 8 and none is closed: D90 raised its two points outside the register (K43, now closed). |
## 1. Fixed constraints
@@ -190,7 +192,7 @@ Interpretations, all RATIFIED (I-1 by OR-14, I-2 and I-4 by OR-9, I-3 by OR-8):
| D52 | Declarative translations | BINDING | Owner-directed 2026-10-17: packages carry `[labels.]` tables for module, model, field, option and action names and descriptions; `/api/meta` delivers them; the bundled packages and built-in modules ship machine-drafted de, fr, pl, fi, sv, es |
| D53 | Two-factor authentication | BINDING | Owner-directed 2026-10-17: RFC 6238 TOTP for people, enforced for administrators by default, secrets sealed under a server key, single-use recovery codes, an append-only `security_event` log; agent tokens are unaffected and cannot manage it; WebAuthn passkeys came with D65; SMS is not built |
| D54 | Connectors and compliant data flows | BINDING | Owner-directed 2026-10-17: a built-in `connectors` app; every flow in or out passes one policy gate (recorded legal basis, field allowlist, treatment by data class, caps, log without payload); one hardened egress client; signed webhooks; OAuth2 with PKCE; user-owned connections with withdrawable consent; a generated processing register. Supports GDPR compliance; it is not legal advice or a certification |
-| D55 | Module metadata, core version and versioned dependencies | BINDING | Owner-directed 2026-10-17: one core version (0.2.0), reported everywhere; every module declares its version, licence, author, core requirement and dependencies with semver ranges; installs resolve dependencies into one confirmed, atomic plan; uninstalls and upgrades that would break a dependent are blocked; the Modules page shows all of it |
+| D55 | Module metadata, core version and versioned dependencies | BINDING | Owner-directed 2026-10-17: one core version (0.2.0), reported everywhere; every module declares its version, licence, author, core requirement and dependencies with semver ranges; installs resolve dependencies into one confirmed, atomic plan; uninstalls and upgrades that would break a dependent are blocked; the Modules page shows all of it. Conforming clause (D88 Decision 14): from the first patch bump the build number is 0.2.N, which is 0.2.1 since the merge of PR 33 (tag `v0.2.1-r1`, 2026-10-29) |
| D56 | Action effects and assertions | BINDING | Owner-directed 2026-10-17: an action may declare `assert` conditions and effects (create, update, adjust, set_state) that run in the same transaction as its transition, with the caller's permissions, all or nothing; the language is declarative, bounded and checked at install |
| D57 | Computed fields, uniqueness and sequences | BINDING | Owner-directed 2026-10-17: fields computed on read from a tiny integer expression language and child aggregates; `unique` and `unique_with` enforced by a checked write and a database index; `sequence` fields numbered from transactional counters, gapless on request |
| D58 | Row-level access | BINDING | Owner-directed 2026-10-17: `[model.access]` rules (own, via, public_field, shared_with, see_all) enforced in every read and write path; an invisible record is a missing record |
@@ -221,18 +223,18 @@ Interpretations, all RATIFIED (I-1 by OR-14, I-2 and I-4 by OR-9, I-3 by OR-8):
| D84 | Team and funding posture | RATIFIED (OR-6) | Self-funded; owner plus AI agents; no gates; schedule feasibility is a named risk |
| D85 | Support promise and release train | RATIFIED (OR-12) for the promise; cadence PROPOSED | N-1 and 12-month deprecation before 1.0; 2-year security-fix window from the first stable release |
| D86 | W1 scope cut | RATIFIED (OR-13, OR-15) for the cut; the slip order PROPOSED | Smallest honest loop, confirmed; a stated slip order; dates kept |
-| 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`) |
+| 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; RATIFIED (OR-36) that every native app works on both Android and iOS; the scope limits and the technical design PROPOSED (Q37 to Q43) | React Native (Expo) apps may be obop clients (OR-34) and every native app works on both Android and iOS, iOS no longer deferred (OR-36); 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`; OR-37 b accepted a patch per commit that widens the manifest and the tag form `v0.2.N-rM` for the release of the field limits, 0.2.1, and nothing else of the mechanism) |
| 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) |
+| D90 | Field limits (`max_length`, `min_length`, `min`, `max`) | PROPOSED; built and merged (PR 33, squash 32a0758, tag `v0.2.1-r1`, the merge approved by the owner); RATIFIED (OR-37) for the default lengths and the caps with the refusal above the default (a), and for the version effect (b), and for nothing else. 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`: RATIFIED, OR-37 a) and a hard cap for an override (**10,000** and **1,000,000**: RATIFIED, OR-37 a), 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` is 0.2.1 (OR-37 b, under OR-35 h: a package that declares a limit writes `requires_core = "^0.2.1"`; the bundled packages keep `^0.2`). The refusal of writes above the default, where there was none, is ruled by OR-37 a; the other visible differences of Decision 9 are listed there and are not named by a ruling |
-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.
+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 was merged on 2026-10-29 as PR 33 (squash 32a0758, tagged `v0.2.1-r1`) with the owner's approval of the merge. It is PROPOSED except for two points that the owner settled on 2026-10-29 (OR-37, two questions of the lead, answered by choosing the recommended option, relayed by the setup session; not quoted): (a) the numbers of Decision 3 (the default and the cap of each text kind) with the refusal of writes above the default (Decision 9 (a)), and (b) the version effect (the keys are a patch release under OR-35 h, `CORE_VERSION` 0.2.1, `^0.2.1` for a package that uses them, the bundled packages `^0.2`, the tag `v0.2.1-r1`). What D90 says about the version rule applies the mechanism of D88 Decision 14, which stays PROPOSED apart from what OR-35 h, j and k and OR-37 b rule (OR-37 b accepts a patch per commit that widens the manifest and the tag form `v0.2.N-rM`, and nothing else of the mechanism). The rest of D90 is the lead's design and adds no owner ruling; the visible differences (c), (d) and (e) of Decision 9 are named by no ruling. D90 raised two points for the owner (the four numbers of Decision 3; the version effect with the refusal where there was none, Decision 9) that were not registered as questions in section 8; OR-37 settled them, no question was added or closed, and the counts below are unchanged. K43 tracked both and is closed.
-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.
+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; it did land first, as 0.2.1, on 2026-10-29), 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`, and later the same day (OR-37 b), for the release of the field limits only, a patch per commit that widens the manifest and the tag form `v0.2.N-rM`; no other part of the mechanism was put to him. 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.
+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), and on 2026-10-29 D87 for OR-36 (every native app works on both Android and iOS) and D90 for the numbers, the refusal above the default and the version effect (OR-37 a and b). 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), 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).
+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) apart from the points ruled by OR-37 (the default lengths and caps with the refusal above the default, and the version effect), 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
@@ -625,7 +627,7 @@ Proposed and awaiting the owner: the scope limits of D87 (first-party apps only,
#### D55 Module metadata, core version and versioned dependencies
**Status** BINDING (owner-directed in chat, 2026-10-17). **Reversibility** two-way door. **Owner action** no.
-**Decision.** *One core version.* The workspace package version (`0.2.0`) is the core version. `obop --version`, `GET /api/meta` (`core_version`), the OpenAPI `info.version`, the MCP server info and card, and the website footer report that one string (`obop_types::CORE_VERSION`). While the core is below 1.0 a minor bump may break (semver 0.y rule); from 1.0 only a major bump may. *Module metadata.* The `[module]` table gains `summary` (at most 140 characters), `license` (an SPDX expression, `LicenseRef-...`, `proprietary` or `UNLICENSED`; the checker is hand-written with a list of the identifiers a module realistically uses), `authors`, `homepage`, `repository`, `documentation`, `support` (https URLs only), `keywords` (at most 10), `categories` (from `finance`, `sales`, `supply-chain`, `people`, `services`, `marketing`, `productivity`, `platform`, `other`; at most 3), `icon`, `maturity` (`stable`, `beta`, `experimental`) and `requires_core` (a semver requirement). Every key is optional when a package is uploaded, so older manifests parse unchanged; a bundled or marketplace package must declare `license` and `requires_core`, and an upload without `requires_core` is assumed to run on every core (`*`) with a warning. *Dependencies.* `depends = ["hr"]` stays valid and means any version; the new table `[module.dependencies]` maps a module id to a requirement (`hr = ">=0.1, <1"`, `base = "^0.2"`, `projects = "*"`). The two forms may not name the same module. A dependency on a built-in module is checked against the core version, because built-ins version with the core. Optional dependencies are not offered: a `ref` to a model of a module that is not installed cannot be honoured by the registry, so a dependency that may be absent would be a lie. *Semver.* A 300-line matcher in `obop-pkg` (no dependency) supports `*`, `1.2.3` (as in Cargo, a caret), `^`, `~`, `>=`, `>`, `<=`, `<`, `=`, partial versions, wildcards, comma-separated ANDs and pre-release ordering, with the npm and Cargo rule that a pre-release satisfies a requirement only when that requirement names a pre-release of the same version. *Resolution.* Installing or upgrading computes a plan before it does anything: the core requirement, every dependency (built-ins against the core, installed packages, marketplace candidates), missing dependencies pulled in transitively in topological order, cycles named (`dependency cycle: a -> b -> a`), an installed dependency upgraded when the marketplace offers a version that fits, and a conflict (an installed module whose requirement the new version would break, for example `projects` 2.0.0 while `timesheets` needs `<2`) as a blocker. Downgrades and re-installs of the same version stay refused. The plan is the read-only admin command `module_plan.preview`; `marketplace_listing.install` and `module.install` run it and apply it as one operation under one approval (an agent proposes the whole plan once), and if a step fails the steps already applied are undone. `without_dependencies: true` is an error: required dependencies cannot be skipped. Uninstall is refused while an installed package needs the module, naming the dependents transitively. `module.list`, `module.get` and `marketplace_listing.list` return the metadata plus `requires_core`, `compatible`, `dependencies` (id, requirement, kind, installed and available version, satisfied), `dependents`, `installable` with `blockers`, `update_available`, `source`, `installed_at` and `installed_by`, for built-in modules too. The marketplace `index.json` listing carries the same metadata so a remote catalogue can show cards without fetching manifests; the first-generation index (five fields) still works. *Licences.* The bundled packages are first-party apps, so they carry `AGPL-3.0-or-later` (D1: core and first-party apps; Apache-2.0 is for SDKs, WIT worlds, the UI SDK and templates, which is why the tutorial module `docs/modules/assets.toml` is `Apache-2.0`). D1 is still PROPOSED (Q1); if the owner picks `-only` or another licence, the one line in each package and in `ModuleMeta::first_party` changes. *Command line.* `obop modules list` shows version, licence, core requirement and source; `modules tree [ID]` the dependency tree; `modules plan FILE|ID` the plan; `modules check [--catalog] FILE...` all the new rules without a database (ranges satisfiable by what exists, licence and core requirement for catalogue use, a core mismatch as a warning for an upload). *The page.* Customize, Modules shows the running core, Installed, Marketplace and Updates, a card per module with licence, maturity, core compatibility, source and a one-line dependency summary, a side panel with Overview, Dependencies (the core requirement and a nested tree with required range, installed version and status), Contents and Versions, a plan dialog before every install and upgrade, an uninstall dialog that names the dependents, an upload area that runs `module_plan.preview` as the file arrives, and an instance-wide dependency overview with a table alternative.
+**Decision.** *One core version.* The workspace package version (`0.2.0`) is the core version (conforming clause, D88 Decision 14: from the first patch bump the build number is 0.2.N, which is 0.2.1 since the merge of PR 33 (tag `v0.2.1-r1`, 2026-10-29)). `obop --version`, `GET /api/meta` (`core_version`), the OpenAPI `info.version`, the MCP server info and card, and the website footer report that one string (`obop_types::CORE_VERSION`). While the core is below 1.0 a minor bump may break (semver 0.y rule); from 1.0 only a major bump may. *Module metadata.* The `[module]` table gains `summary` (at most 140 characters), `license` (an SPDX expression, `LicenseRef-...`, `proprietary` or `UNLICENSED`; the checker is hand-written with a list of the identifiers a module realistically uses), `authors`, `homepage`, `repository`, `documentation`, `support` (https URLs only), `keywords` (at most 10), `categories` (from `finance`, `sales`, `supply-chain`, `people`, `services`, `marketing`, `productivity`, `platform`, `other`; at most 3), `icon`, `maturity` (`stable`, `beta`, `experimental`) and `requires_core` (a semver requirement). Every key is optional when a package is uploaded, so older manifests parse unchanged; a bundled or marketplace package must declare `license` and `requires_core`, and an upload without `requires_core` is assumed to run on every core (`*`) with a warning. *Dependencies.* `depends = ["hr"]` stays valid and means any version; the new table `[module.dependencies]` maps a module id to a requirement (`hr = ">=0.1, <1"`, `base = "^0.2"`, `projects = "*"`). The two forms may not name the same module. A dependency on a built-in module is checked against the core version, because built-ins version with the core. Optional dependencies are not offered: a `ref` to a model of a module that is not installed cannot be honoured by the registry, so a dependency that may be absent would be a lie. *Semver.* A 300-line matcher in `obop-pkg` (no dependency) supports `*`, `1.2.3` (as in Cargo, a caret), `^`, `~`, `>=`, `>`, `<=`, `<`, `=`, partial versions, wildcards, comma-separated ANDs and pre-release ordering, with the npm and Cargo rule that a pre-release satisfies a requirement only when that requirement names a pre-release of the same version. *Resolution.* Installing or upgrading computes a plan before it does anything: the core requirement, every dependency (built-ins against the core, installed packages, marketplace candidates), missing dependencies pulled in transitively in topological order, cycles named (`dependency cycle: a -> b -> a`), an installed dependency upgraded when the marketplace offers a version that fits, and a conflict (an installed module whose requirement the new version would break, for example `projects` 2.0.0 while `timesheets` needs `<2`) as a blocker. Downgrades and re-installs of the same version stay refused. The plan is the read-only admin command `module_plan.preview`; `marketplace_listing.install` and `module.install` run it and apply it as one operation under one approval (an agent proposes the whole plan once), and if a step fails the steps already applied are undone. `without_dependencies: true` is an error: required dependencies cannot be skipped. Uninstall is refused while an installed package needs the module, naming the dependents transitively. `module.list`, `module.get` and `marketplace_listing.list` return the metadata plus `requires_core`, `compatible`, `dependencies` (id, requirement, kind, installed and available version, satisfied), `dependents`, `installable` with `blockers`, `update_available`, `source`, `installed_at` and `installed_by`, for built-in modules too. The marketplace `index.json` listing carries the same metadata so a remote catalogue can show cards without fetching manifests; the first-generation index (five fields) still works. *Licences.* The bundled packages are first-party apps, so they carry `AGPL-3.0-or-later` (D1: core and first-party apps; Apache-2.0 is for SDKs, WIT worlds, the UI SDK and templates, which is why the tutorial module `docs/modules/assets.toml` is `Apache-2.0`). D1 is still PROPOSED (Q1); if the owner picks `-only` or another licence, the one line in each package and in `ModuleMeta::first_party` changes. *Command line.* `obop modules list` shows version, licence, core requirement and source; `modules tree [ID]` the dependency tree; `modules plan FILE|ID` the plan; `modules check [--catalog] FILE...` all the new rules without a database (ranges satisfiable by what exists, licence and core requirement for catalogue use, a core mismatch as a warning for an upload). *The page.* Customize, Modules shows the running core, Installed, Marketplace and Updates, a card per module with licence, maturity, core compatibility, source and a one-line dependency summary, a side panel with Overview, Dependencies (the core requirement and a nested tree with required range, installed version and status), Contents and Versions, a plan dialog before every install and upgrade, an uninstall dialog that names the dependents, an upload area that runs `module_plan.preview` as the file arrives, and an instance-wide dependency overview with a table alternative.
**Why.** The owner found the Modules page unclear about dependencies. The facts that decide whether an install is safe (which core, which other modules and at which versions, under which licence) were not declared, so neither the page nor an agent could say them before the install. Declaring them in the manifest and resolving them in the one installer keeps REST, MCP, the CLI and the page in agreement, and makes the install a plan a person confirms once.
@@ -995,7 +997,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
*D79 note 2026-10-28 (Proposed, D89; not built).* Section 9 (side channels) of this record lists, among the accepted residuals that it plans to pin by a test, that a refused delete says that records the caller cannot see refer to it. D89 would remove that residual (a neutral refusal, with one bit that remains for `refuse`) and says that "someone who may delete it" is true only once the DELETE position of this record exists; until then it means the write group plus visibility (OR-35 e).
#### D88 Model rules: a write guard on a model, over the record, one parent hop and the clock
-**Status** PROPOSED (a design of 2026-10-28 for asks 11.2 and 11.5 of the platform team, a project that builds on obop; nothing is built; not ratified as binding), except four points that are RATIFIED by OR-35 (the owner's answers of 2026-10-28 and 2026-10-29, relayed by the setup session, not quoted): delete is not covered by model rules, for now (part f; Decision 4, slice 3, Q61), the amendment of MODULES.md 3.1 that lets a patch release carry additions, with the bundled packages kept at `^0.2` (part h; Decision 14), API fields and field descriptions in patch releases (part j; Decision 14), 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; part k; Decision 14). Two points were settled by the lead session on 2026-10-28 and the setup session did not object; they are lead decisions, not owner rulings, and stay PROPOSED with the rest of the record: rule refusals reuse `assertion_failed` and `assert_key` and add `assert_scope` (no new code), and 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 (two for D88: the manifest commit of slice 1 and slice 2; two for D89), not 0.3.0 (Decision 14; the owner ruled only the amendment of MODULES.md 3.1 and that the bundled packages keep `^0.2`, OR-35 h, j and k). A third lead decision, of 2026-10-29, applies to this record and to D89: the texts of part (k) may change in a patch release only where a decision record lists the change as a visible difference and the release notes list it; it is applied by analogy to part (h), the owner was not shown it for part (k), and it stays PROPOSED (Decision 14). **Reversibility** a two-way door until a package declares `[[model.rule]]`: the key is new and optional, a model without a rule behaves exactly as before, and a rule lives in the package document, so dropping it is a plain `module.rollback`. A one-way door afterwards: the manifest key and its `applies` values, the label slot `rule..message`, the body key `assert_scope`, the wording `(rule failed)` and the version number. **Owner action** none. Q57 and Q58 were answered by the platform team on 2026-10-28 (relayed by the setup session; platform answers, not owner rulings) and Q61 (must a closed poll's result be final) by the owner (OR-35 f); all three are closed.
+**Status** PROPOSED (a design of 2026-10-28 for asks 11.2 and 11.5 of the platform team, a project that builds on obop; nothing is built; not ratified as binding), except four points that are RATIFIED by OR-35 (the owner's answers of 2026-10-28 and 2026-10-29, relayed by the setup session, not quoted): delete is not covered by model rules, for now (part f; Decision 4, slice 3, Q61), the amendment of MODULES.md 3.1 that lets a patch release carry additions, with the bundled packages kept at `^0.2` (part h; Decision 14), API fields and field descriptions in patch releases (part j; Decision 14), 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; part k; Decision 14). Two points were settled by the lead session on 2026-10-28 and the setup session did not object; they are lead decisions, not owner rulings, and stay PROPOSED with the rest of the record: rule refusals reuse `assertion_failed` and `assert_key` and add `assert_scope` (no new code), and 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 (two for D88: the manifest commit of slice 1 and slice 2; two for D89), not 0.3.0 (Decision 14; the owner ruled only the amendment of MODULES.md 3.1 and that the bundled packages keep `^0.2`, OR-35 h, j and k, and, for the release of the field limits only, a patch per commit that widens the manifest and the tag form `v0.2.N-rM`, OR-37 b). A third lead decision, of 2026-10-29, applies to this record and to D89: the texts of part (k) may change in a patch release only where a decision record lists the change as a visible difference and the release notes list it; it is applied by analogy to part (h), the owner was not shown it for part (k), and it stays PROPOSED (Decision 14). **Reversibility** a two-way door until a package declares `[[model.rule]]`: the key is new and optional, a model without a rule behaves exactly as before, and a rule lives in the package document, so dropping it is a plain `module.rollback`. A one-way door afterwards: the manifest key and its `applies` values, the label slot `rule..message`, the body key `assert_scope`, the wording `(rule failed)` and the version number. **Owner action** none. Q57 and Q58 were answered by the platform team on 2026-10-28 (relayed by the setup session; platform answers, not owner rulings) and Q61 (must a closed poll's result be final) by the owner (OR-35 f); all three are closed.
**Source.** Asks 11.2 (a write guard that can read the parent record and the clock) and 11.5 (consistency between a record and the records it points at) of the amendment of 2026-10-28 to the platform team's asks (kept outside this repository; its Ask and Why cells name no downstream customer or product). What the team measured on `v0.2.0-r26`: a vote for a poll that closed in 2020 is accepted; a vote's poll can differ from its option's poll, so one person's votes for one option were counted four times by filing each under another poll; a reply names a parent on another post; a reaction to a comment names another post. The count of a poll is the product of the feature. The acceptance sentences the design is held to: 11.2 (a vote refused after `closes_at`; the same row in a `bulk_create` refuses the whole call and says which row; an update that moves a vote after closing is refused; the Swedish message for `Accept-Language: sv`; models without a guard behave as before) and 11.5 (a vote whose poll differs from its option's poll answers 400 with `code`, `hint` and `assert_key`; a reply on another post and a reply to itself are refused; models without a rule behave as before). The 11.5 sentence "an update that moves a vote to another poll is refused" is read, on the platform team's answer of 2026-10-28 (Q57), as "an update that makes a vote's poll differ from its option's poll is refused"; the team corrects its own document. The design was made read-only against `v0.2.0-r31` (7bb4304) and reviewed adversarially three times (31 findings, all applied). Read with D40, D48, D52, D56, D57, D58, D70, D74, D75, D76, D77, D78, D80 and D89.
@@ -1027,7 +1029,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
*13. Interplay.* D40: the verdict is one boolean and static text; no parent value leaves the engine; the gate keeps secret, sensitive, personal-data and unclassified fields out and the write-time predicate repeats the `query_guard` test per caller, so a rule is no oracle for an agent that sees only tokens. D48: `create` starts a governed field at its first option and `update` moves it only as an implicit action, so a forged state is refused before a rule sees it; a rule may read a governed field of a parent (`poll_id.status == "open"` is a legal way to close by state machine) but not of its own model. D52: the label slot above. D56: assertions are unchanged and stay action-only; a rule key may equal an assertion key of the same model, and `assert_scope` tells them apart. D57: rules run before `check_unique`; `unique_with` through a ref is not added (an index spans a row's own columns and a parent value changes with the parent; the rule plus an own-field `unique_with` states the same invariant and leaves the keyed lookup of ask 11.4 independent). D58: parent visibility uses `Gate::visible`, so a later change of the gate (D79 scoped rules) flows through; `creator_blocked` runs first. D70: section 8 of the Decision; rate limit and idempotency untouched. D72: instants are canonical UTC microsecond text and compare as instants. D74: items 5, 8 and 9. D75: a rule does not read system fields; history is written by `Tracked` at commit and never sees a refused write. D76 and D78: unaffected; rules are evaluated in memory on one record, compile to no SQL and declare no index. D77: complementary (state-based freezing and database-enforced guards); D77's check runs first inside `Unit`, and it would close D88-gap-1 for the fields it freezes. D80: item 12. D89: the writes that D89 adds to `Unit` (`Unit::patch` for a `set_null` and `Unit::delete_if` for a cascaded row) are engine-authority writes and are not checked by model rules, by design. A rule refusal would reach a caller who only asked to delete a different record, and D89 answers a failed cascade with its own neutral `delete_blocked`. The `set_null` write is the named `Exempt::Cascade` of Decision 4, pinned by the lint; the cascaded delete needs no exemption because `Unit::delete` is not covered by rules. If D89 lands first, its `Unit::patch` is written as a plain merge (nothing checks anything yet) and commit 4 of D88 slice 1 converts it to `merge_unchecked(.., Exempt::Cascade)`; the lint test fails until it does. The consequence is a residue of the kind of D88-gap-1: a `set_null` can leave a stored record that violates a rule. D89's install gate refuses `set_null` on a field that a rule of the registry being checked reads (an own field, a head, or the parent field of a path), so the known case is not left to chance; a rule added later by another package that reads such a field is not caught. Both records also edit `refresh_lock_models` (the lock set is the union), the error table, `/api/meta` and the version number (each takes its own patch number, Decision 14). PostgreSQL and SQLite behave the same by construction: no SQL is added; only the lock implementation differs.
-*14. Compatibility and the core version.* Additive for packages: a model with no `[[model.rule]]` has empty `ext.rules`, an unchanged `has_rules()`, an unchanged lock set (a test pins set equality for the bundled modules), a byte-identical meta and no extra read. One change to existing behaviour is listed so that it is not silent: the `assertion_failed` hint is reworded; the bodies of existing assertion refusals gain no new key (no `assert_scope`) and only their `hint` text changes. Storage faults in a bulk call do not change (Decision 8). `ModelM` is `deny_unknown_fields`, so an older core rejects a package with `[[model.rule]]` at parse time, and while every image reports `0.2.0` the number in `requires_core` cannot say "the first core that has rules"; the version rule below changes that. *The version rule* (a lead decision of 2026-10-28, not an owner ruling, except what OR-35 h, j and k of 2026-10-29 rule: that a patch release may carry the additions of the amendment of MODULES.md 3.1 below, and that the 23 bundled packages are not moved to 0.3.0 and keep `^0.2`; the owner was not asked about patch numbering, the `^0.2.N` spelling, the tag form or the test names, which stay the lead's design and PROPOSED; it replaces a first draft that proposed 0.2.1 for D88 and 0.3.0 for D89): no 0.3.0. Each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line (`CORE_VERSION` is the workspace version, `env!("CARGO_PKG_VERSION")`), in the order the commits land. There are four such commits, two for each record: for D88 the manifest commit of slice 1 (the table `[[model.rule]]`, commit 7 of the list) and the commit of slice 2 that lets `applies = "frozen"` and `now()` parse; for D89 slice 1 (the key `on_delete` parses) and slice 2 (the flip of `ON_DELETE_ENGINE`, which makes `cascade` and `set_null` parse; D89 Decision 15). The first of the four to land is 0.2.1, the next 0.2.2, and so on; if D88 slice 1 lands first it is 0.2.1 and the first D89 commit after it is 0.2.2, and if D89 lands first, the other way round (examples, not commitments). A package that uses the feature declares `requires_core = "^0.2.N"`, N being the number of the commit that introduced what it uses (`^0.2.N` means `>=0.2.N, <0.3.0`; a bare `>=0.2.N` has no upper bound, which `Manifest::catalog_problems` refuses for a catalogue package). The parser does not check that the declared number is high enough for the keys the package uses; the release drill and the documentation do. *What the number does and does not do (read in `manifest.rs`, `resolve.rs`, `views.rs` and `obop-update`, not run).* `Manifest::parse_as` runs `toml::from_str` on the whole document, with `deny_unknown_fields` on the tables, before `validate()` reads `requires_core`, and every path that installs a package parses first (the upload plan, `fetch_market`, `install`). So on an older core a package that uses a new key always fails at parse time, with the parser's message about an unknown field, whatever `requires_core` says: the number never turns that into a core blocker. The update check of D63 does not see it either, because it judges only releases newer than the running core (`judge` in `obop-update`) and asks whether the installed packages would still run on that newer core. What the number does do: the module list, which is built from the catalogue index (that carries `requires_core`), shows `compatible: false` for an entry whose range excludes the running core; the resolver gives a core blocker (`needs core ..., you run ...`) for a manifest that parsed and whose range excludes the core, which is what a core above the range meets (`^0.2.N` stops below 0.3.0); and the number tells a reader which build introduced the key. Direct installs, uploads and the CLI on an older core show the parse error. The number does not stop an image rollback either: `rollback()` in `obop-update` does not read `requires_core`, so a rollback to an image without the key is not refused whatever the numbers. The hazard therefore stays (D89 Decision 15 says what the older image does). The release drill of slice 1 starts the previous image on a database with a package that has a rule and records what happens, and slice 2 repeats it for the rollback from the slice-2 image to the slice-1 image, because a package that uses `frozen` or `now()` loads on the slice-1 image only as a parse refusal. *Why not 0.3.0.* Below 1.0 a minor bump is breaking in the compatibility policy of MODULES.md section 3.1, and `^0.2` stops below 0.3.0 (`semver.rs` pins `!ok("^0.2", "0.3.0")`). All 23 bundled packages declare `requires_core = "^0.2"`; 12 of their dependency lines (6 on `base` and 6 on `product`, both built in) are `^0.2` and follow the core, because built-in modules take the core's version, and a 13th (`sign_docuseal` on the bundled package `sign`) is also `^0.2` but names a package version and does not follow the core. A core of 0.3.0 would make the whole bundled catalogue incompatible: the resolver would block installs and the update check of D63 would list every installed module as a blocker. A patch number keeps `^0.2` true, so no bundled package, template or dependency range changes. *Tag form, and OR-32.* The git tags become `v0.2.1-r1` for the first commit with 0.2.1 (the `rN` counter restarting is the lead's proposal; tags are made by hand and the latest, at 2026-10-29, is `v0.2.0-r32`); the image is tagged by commit and by branch (`.github/workflows/image.yml`), not by the git tag, and a push of a tag builds no image (docs/ISSUES.md K42). OR-32 (RATIFIED) says: the calendar's 0.1, 0.2 and 0.5 are product milestones (their labels are Proposed); the code's `0.2.0` core version and `v0.2.0-rN` tags are the running build number until the first milestone is announced. The other labels of the calendar (0.3 public alpha on 2027-04-06 with the N-1 guarantee, 0.4, 0.6) are the ROADMAP's, and Proposed there. This rule moves the build number inside the 0.2 line and takes none of the milestone labels. *Not reconciled, and said plainly.* (1) The literal `0.2.0` in OR-32, in D55 (the row and the Decision), in the Alignment rules paragraph of this register and in MODULES.md line 21 ("Built-in modules version with the core (`0.2.0`, D55)") reads as the number of the whole line. OR-32 and D55 are ruled, so they are not edited here. The conforming edit is assigned to the commit that carries the docs of whichever of the four commits lands first: commit 8 of D88 slice 1 (surfaces and docs) if D88 is first, or the commit of D89 slice 1 that carries the docs if D89 is first (the later record then has nothing to do), and it is made only if the version rule stands: OR-32 and D55 keep their wording and each gets one clause (from the first patch bump the build number is 0.2.N, D88 Decision 14), and the Alignment rules sentence is corrected the same way; the D79 sentence as it stood on main is already corrected by this record (it said that the core version stays 0.2.0 and that 22 packages say `^0.2`). (2) MODULES.md section 3.1 (Compatibility policy) said until OR-35 h, j and k that a patch bump never breaks manifests, commands or the API. That would not have been true of the visible differences of these two records: the reworded `assertion_failed` hint (this record) and, in D89 Decision 15, the neutral delete refusal, the 409 `delete_blocked` and its new body keys. The version rule includes a minimal amendment of that sentence, accepted by the owner on 2026-10-29 in three questions (OR-35 h: whether a patch release may add optional manifest keys, values, functions and routes, and change the text or the status of a refusal where a decision record and the release notes list it, or whether that needs a 0.3.0 bump, which would make all 23 bundled packages incompatible; OR-35 j: whether it may also add API fields and change descriptions, meaning field descriptions in the MCP and OpenAPI documents; OR-35 k, put after j: whether it may also change the generated texts that tell agents what each operation does, meaning the descriptions of the create, update and bulk_create tools, of every delete, and of security_event), and MODULES.md section 3.1 carries it as spec text: a patch release does not remove or rename a manifest key, a command or an API field; it may add optional manifest keys, values of an existing key, functions of the expression language, API fields and routes, it may change field descriptions, and it may change the text or the status of a refusal, or those generated texts that tell agents what each operation does, where a decision record lists the change as a visible difference and the release notes list it. The listing condition for the texts of part (k) is the record's, not the owner's: the relay of part (k) names no safeguard, and the record applies the condition of part (h) to these texts by analogy (a lead decision of 2026-10-29, PROPOSED, which the owner can correct), whereas part (j) has no such condition. The amendment weakens a promise that the documentation made until then, and the owner accepted the amendment that weakens it, without that condition for the texts of part (k). The rest of the version rule (which commit takes which patch number, the `^0.2.N` spelling, the tag form and the test names) stays a lead decision and PROPOSED. *What the visible differences of this record rest on.* The text or status of a refusal (OR-35 h): the reworded `assertion_failed` hint. API fields and field descriptions (OR-35 j): `assert_scope` in the `Error` schema, `assert_key` on the `errors[]` items of the bulk schema, the key `rules` of a model in `/api/meta`, and the amended description of `assert_key`. The generated texts that tell agents what each operation does (OR-35 k): the generated MCP descriptions of `create`, `update` and `bulk_create` of a model with rules (Decision 12), which are descriptions of tools and not of fields, so that part (j) does not cover them and part (k) does (these are the MCP tool descriptions built from the registry strings of `ModelDef::all_commands`, `crates/kernel/obop-registry/src/lib.rs:435`, `:439` and `:457`, as of 2026-10-29; the OpenAPI summaries of `create`, `update` and `bulk_create`, `crates/kernel/obop-http/src/openapi.rs:344`, `:414` and `:383`, are not named by part (k) and this record does not change them). *Tests (slice 1).* `core_version_requirement_model_rule`: a package using `[[model.rule]]` declares `requires_core = "^0.2.N"`, with N written in the test as the number of the manifest commit and not computed from `CORE_VERSION` (later commits raise it); the test asserts that `0.2.N` and the running `CORE_VERSION` satisfy the range and that `0.2.(N-1)` does not. The bump commit also changes every pin of the literal core version, found by a grep of the tree at 7bb4304 (the list is a checklist for that commit, CI finds a missed one, and every later bump changes them again): in `crates/obop/src/modules_tests.rs` the lines 61 (`the_core_version_is_the_workspace_version`), 246 (the version of the built-in `base`), 411 (the tree text `core 0.2.0`), 469 (`core_version` of `/api/meta`), 471 (the OpenAPI `info.version`) and 483 (`core_version` of the plan); `crates/obop/tests/version.rs` line 41 (`obop 0.2.0 (`); `web/packages/client/openapi.json` line 1570 with its drift test `the_committed_openapi_document_is_current` in `crates/obop/src/openapi_tests.rs` (refresh with `OBOP_UPDATE_OPENAPI=1`, then `vp run generate:types`; CI also checks the generated types); `web/apps/site/data/builtin/connectors.toml` with the test `the_website_snapshot_matches_the_module` in `crates/modules/connectors/src/tests.rs` (refresh with `OBOP_UPDATE_SNAPSHOT=1`) and the five hand-kept snapshots in that folder (`base`, `crm`, `invoicing`, `product`, `sales`), which carry `version = "0.2.0"` and have no test; and six assertions of `web/packages/e2e/tests/modules.spec.ts` (lines 61, 66, 225, 239, 327 and 335). Examples that no test checks: `docs/DEPLOY.md` line 644, `docs/spec/cli.md` line 65, the `--version` line of the help text in `crates/obop/src/main.rs`, and comments in `crates/obop/src/build_commit.rs` line 30, `crates/obop/tests/version.rs` line 29 and `crates/kernel/obop-ops/src/backup.rs` line 9 (CI does not find a stale comment or a stale document, so these belong to the checklist). `crates/obop/src/health_tests.rs` line 151 asserts that the health bodies do not contain `0.2.0`; it stays green after a bump but then checks the wrong string, so the bump commit changes it too. The tests of the bundled packages are pins: they pass before and after because `^0.2` still holds. Existing rows are not re-validated (D88-gap-6). Adding or removing a rule is not a breaking package change in the sense of MODULES.md section 7 (no stored value or shape changes), but it tightens writes, and an action of another package that writes the model by an effect is subject to it. Nothing in `docs/research/` changes, and no `requires_core` or dependency range of a bundled package changes.
+*14. Compatibility and the core version.* Additive for packages: a model with no `[[model.rule]]` has empty `ext.rules`, an unchanged `has_rules()`, an unchanged lock set (a test pins set equality for the bundled modules), a byte-identical meta and no extra read. One change to existing behaviour is listed so that it is not silent: the `assertion_failed` hint is reworded; the bodies of existing assertion refusals gain no new key (no `assert_scope`) and only their `hint` text changes. Storage faults in a bulk call do not change (Decision 8). `ModelM` is `deny_unknown_fields`, so an older core rejects a package with `[[model.rule]]` at parse time, and while every image reports `0.2.0` the number in `requires_core` cannot say "the first core that has rules"; the version rule below changes that. *The version rule* (a lead decision of 2026-10-28, not an owner ruling, except what OR-35 h, j and k of 2026-10-29 rule: that a patch release may carry the additions of the amendment of MODULES.md 3.1 below, and that the 23 bundled packages are not moved to 0.3.0 and keep `^0.2`; the owner was not asked about patch numbering, the `^0.2.N` spelling, the tag form or the test names in those three questions, which stay the lead's design and PROPOSED except for what OR-37 b of the same day accepted for the release of the field limits (a patch per commit that widens the manifest, the tag form `v0.2.N-rM`, and `^0.2.1` for the keys of D90); it replaces a first draft that proposed 0.2.1 for D88 and 0.3.0 for D89): no 0.3.0. Each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line (`CORE_VERSION` is the workspace version, `env!("CARGO_PKG_VERSION")`), in the order the commits land. There are four such commits, two for each record: for D88 the manifest commit of slice 1 (the table `[[model.rule]]`, commit 7 of the list) and the commit of slice 2 that lets `applies = "frozen"` and `now()` parse; for D89 slice 1 (the key `on_delete` parses) and slice 2 (the flip of `ON_DELETE_ENGINE`, which makes `cascade` and `set_null` parse; D89 Decision 15). The first of the four to land is 0.2.1, the next 0.2.2, and so on; if D88 slice 1 lands first it is 0.2.1 and the first D89 commit after it is 0.2.2, and if D89 lands first, the other way round (examples, not commitments). A package that uses the feature declares `requires_core = "^0.2.N"`, N being the number of the commit that introduced what it uses (`^0.2.N` means `>=0.2.N, <0.3.0`; a bare `>=0.2.N` has no upper bound, which `Manifest::catalog_problems` refuses for a catalogue package). The parser does not check that the declared number is high enough for the keys the package uses; the release drill and the documentation do. *What the number does and does not do (read in `manifest.rs`, `resolve.rs`, `views.rs` and `obop-update`, not run).* `Manifest::parse_as` runs `toml::from_str` on the whole document, with `deny_unknown_fields` on the tables, before `validate()` reads `requires_core`, and every path that installs a package parses first (the upload plan, `fetch_market`, `install`). So on an older core a package that uses a new key always fails at parse time, with the parser's message about an unknown field, whatever `requires_core` says: the number never turns that into a core blocker. The update check of D63 does not see it either, because it judges only releases newer than the running core (`judge` in `obop-update`) and asks whether the installed packages would still run on that newer core. What the number does do: the module list, which is built from the catalogue index (that carries `requires_core`), shows `compatible: false` for an entry whose range excludes the running core; the resolver gives a core blocker (`needs core ..., you run ...`) for a manifest that parsed and whose range excludes the core, which is what a core above the range meets (`^0.2.N` stops below 0.3.0); and the number tells a reader which build introduced the key. Direct installs, uploads and the CLI on an older core show the parse error. The number does not stop an image rollback either: `rollback()` in `obop-update` does not read `requires_core`, so a rollback to an image without the key is not refused whatever the numbers. The hazard therefore stays (D89 Decision 15 says what the older image does). The release drill of slice 1 starts the previous image on a database with a package that has a rule and records what happens, and slice 2 repeats it for the rollback from the slice-2 image to the slice-1 image, because a package that uses `frozen` or `now()` loads on the slice-1 image only as a parse refusal. *Why not 0.3.0.* Below 1.0 a minor bump is breaking in the compatibility policy of MODULES.md section 3.1, and `^0.2` stops below 0.3.0 (`semver.rs` pins `!ok("^0.2", "0.3.0")`). All 23 bundled packages declare `requires_core = "^0.2"`; 12 of their dependency lines (6 on `base` and 6 on `product`, both built in) are `^0.2` and follow the core, because built-in modules take the core's version, and a 13th (`sign_docuseal` on the bundled package `sign`) is also `^0.2` but names a package version and does not follow the core. A core of 0.3.0 would make the whole bundled catalogue incompatible: the resolver would block installs and the update check of D63 would list every installed module as a blocker. A patch number keeps `^0.2` true, so no bundled package, template or dependency range changes. *Tag form, and OR-32.* The git tags become `v0.2.1-r1` for the first commit with 0.2.1 (the `rN` counter restarting is the lead's proposal; tags are made by hand and the latest, at 2026-10-29, is `v0.2.0-r32`); the image is tagged by commit and by branch (`.github/workflows/image.yml`), not by the git tag, and a push of a tag builds no image (docs/ISSUES.md K42). OR-32 (RATIFIED) says: the calendar's 0.1, 0.2 and 0.5 are product milestones (their labels are Proposed); the code's `0.2.0` core version and `v0.2.0-rN` tags are the running build number until the first milestone is announced. The other labels of the calendar (0.3 public alpha on 2027-04-06 with the N-1 guarantee, 0.4, 0.6) are the ROADMAP's, and Proposed there. This rule moves the build number inside the 0.2 line and takes none of the milestone labels. *Not reconciled, and said plainly.* (1) The literal `0.2.0` in OR-32, in D55 (the row and the Decision), in the Alignment rules paragraph of this register and in MODULES.md line 21 ("Built-in modules version with the core (`0.2.0`, D55)") reads as the number of the whole line. OR-32 and D55 are ruled, so they are not edited here. The conforming edit is assigned to the commit that carries the docs of whichever of the four commits lands first: commit 8 of D88 slice 1 (surfaces and docs) if D88 is first, or the commit of D89 slice 1 that carries the docs if D89 is first (the later record then has nothing to do), and it is made only if the version rule stands: OR-32 and D55 keep their wording and each gets one clause (from the first patch bump the build number is 0.2.N, D88 Decision 14), and the Alignment rules sentence is corrected the same way; the D79 sentence as it stood on main is already corrected by this record (it said that the core version stays 0.2.0 and that 22 packages say `^0.2`). (2) MODULES.md section 3.1 (Compatibility policy) said until OR-35 h, j and k that a patch bump never breaks manifests, commands or the API. That would not have been true of the visible differences of these two records: the reworded `assertion_failed` hint (this record) and, in D89 Decision 15, the neutral delete refusal, the 409 `delete_blocked` and its new body keys. The version rule includes a minimal amendment of that sentence, accepted by the owner on 2026-10-29 in three questions (OR-35 h: whether a patch release may add optional manifest keys, values, functions and routes, and change the text or the status of a refusal where a decision record and the release notes list it, or whether that needs a 0.3.0 bump, which would make all 23 bundled packages incompatible; OR-35 j: whether it may also add API fields and change descriptions, meaning field descriptions in the MCP and OpenAPI documents; OR-35 k, put after j: whether it may also change the generated texts that tell agents what each operation does, meaning the descriptions of the create, update and bulk_create tools, of every delete, and of security_event), and MODULES.md section 3.1 carries it as spec text: a patch release does not remove or rename a manifest key, a command or an API field; it may add optional manifest keys, values of an existing key, functions of the expression language, API fields and routes, it may change field descriptions, and it may change the text or the status of a refusal, or those generated texts that tell agents what each operation does, where a decision record lists the change as a visible difference and the release notes list it. The listing condition for the texts of part (k) is the record's, not the owner's: the relay of part (k) names no safeguard, and the record applies the condition of part (h) to these texts by analogy (a lead decision of 2026-10-29, PROPOSED, which the owner can correct), whereas part (j) has no such condition. The amendment weakens a promise that the documentation made until then, and the owner accepted the amendment that weakens it, without that condition for the texts of part (k). The rest of the version rule (which commit takes which patch number, the `^0.2.N` spelling, the tag form and the test names) stays a lead decision and PROPOSED, except that OR-37 b (2026-10-29, a later question separate from OR-35) accepted a patch per commit that widens the manifest and the tag form `v0.2.N-rM`, and `^0.2.1` for a package that uses the keys of D90, the release of which is 0.2.1; no other part of the mechanism was put to the owner. *What the visible differences of this record rest on.* The text or status of a refusal (OR-35 h): the reworded `assertion_failed` hint. API fields and field descriptions (OR-35 j): `assert_scope` in the `Error` schema, `assert_key` on the `errors[]` items of the bulk schema, the key `rules` of a model in `/api/meta`, and the amended description of `assert_key`. The generated texts that tell agents what each operation does (OR-35 k): the generated MCP descriptions of `create`, `update` and `bulk_create` of a model with rules (Decision 12), which are descriptions of tools and not of fields, so that part (j) does not cover them and part (k) does (these are the MCP tool descriptions built from the registry strings of `ModelDef::all_commands`, `crates/kernel/obop-registry/src/lib.rs:435`, `:439` and `:457`, as of 2026-10-29; the OpenAPI summaries of `create`, `update` and `bulk_create`, `crates/kernel/obop-http/src/openapi.rs:344`, `:414` and `:383`, are not named by part (k) and this record does not change them). *Tests (slice 1).* `core_version_requirement_model_rule`: a package using `[[model.rule]]` declares `requires_core = "^0.2.N"`, with N written in the test as the number of the manifest commit and not computed from `CORE_VERSION` (later commits raise it); the test asserts that `0.2.N` and the running `CORE_VERSION` satisfy the range and that `0.2.(N-1)` does not. The bump commit also changes every pin of the literal core version, found by a grep of the tree at 7bb4304 (the list is a checklist for that commit, CI finds a missed one, and every later bump changes them again): in `crates/obop/src/modules_tests.rs` the lines 61 (`the_core_version_is_the_workspace_version`), 246 (the version of the built-in `base`), 411 (the tree text `core 0.2.0`), 469 (`core_version` of `/api/meta`), 471 (the OpenAPI `info.version`) and 483 (`core_version` of the plan); `crates/obop/tests/version.rs` line 41 (`obop 0.2.0 (`); `web/packages/client/openapi.json` line 1570 with its drift test `the_committed_openapi_document_is_current` in `crates/obop/src/openapi_tests.rs` (refresh with `OBOP_UPDATE_OPENAPI=1`, then `vp run generate:types`; CI also checks the generated types); `web/apps/site/data/builtin/connectors.toml` with the test `the_website_snapshot_matches_the_module` in `crates/modules/connectors/src/tests.rs` (refresh with `OBOP_UPDATE_SNAPSHOT=1`) and the five hand-kept snapshots in that folder (`base`, `crm`, `invoicing`, `product`, `sales`), which carry `version = "0.2.0"` and have no test; and six assertions of `web/packages/e2e/tests/modules.spec.ts` (lines 61, 66, 225, 239, 327 and 335). Examples that no test checks: `docs/DEPLOY.md` line 644, `docs/spec/cli.md` line 65, the `--version` line of the help text in `crates/obop/src/main.rs`, and comments in `crates/obop/src/build_commit.rs` line 30, `crates/obop/tests/version.rs` line 29 and `crates/kernel/obop-ops/src/backup.rs` line 9 (CI does not find a stale comment or a stale document, so these belong to the checklist). `crates/obop/src/health_tests.rs` line 151 asserts that the health bodies do not contain `0.2.0`; it stays green after a bump but then checks the wrong string, so the bump commit changes it too. The tests of the bundled packages are pins: they pass before and after because `^0.2` still holds. Existing rows are not re-validated (D88-gap-6). Adding or removing a rule is not a breaking package change in the sense of MODULES.md section 7 (no stored value or shape changes), but it tightens writes, and an action of another package that writes the model by an effect is subject to it. Nothing in `docs/research/` changes, and no `requires_core` or dependency range of a bundled package changes.
*15. Slices, in build order.* *Slice 1: parent-path rules and the whole mechanism (asks 11.5, and 11.2 without the clock; size L).* Eight commits, each green, the manifest key and the version bump wired last so that no package can declare an unenforced rule: (1) the language (`Expr::Path`, `IsSetPath`, the parser arm outside aggregates, `fields()` with heads, `paths()`, `check_rule`, the evaluator defaults, `id` in rule scope, strict reading of decimals and datetimes); (2) the registry (`ModelRule`, `Applies::{Touched, Always}`, `ModelExt.rules`, `rule()`, `has_rules`); (3) the gate and the labels (`check_model_rules` items 1 to 8, `ModelTr.rules`, `check_labels`, `rule_message`); (4) the engine (`engine/rules.rs`, the `Unit` writer, `Engine::unit_for`, `merge_unchecked` and `Exempt::{Transition, Backfill, Cascade}` (the last has no caller until D89 slice 2), the lint, the `update_guard` pre-flight); (5) the bulk loop; (6) `refresh_lock_models`, the hint, `assert_scope`; (7) the manifest (`RuleM`, item 9, the code-prefix check, the version bump and the pins of Decision 14); (8) the surfaces and docs, including the conforming edit of OR-32, D55, the Alignment rules and MODULES.md line 21 (Decision 14; D89 slice 1 does it instead if D89 lands first). No `now()`, no `frozen`, no `_actor_now`. *Slice 2: the clock and `frozen` (completes ask 11.2; size M).* `Expr::Now` and its parser arm, `Env::now`, the Dispatcher stamp in `call` and `approve`, the copy into `pre` inputs, `Applies::Frozen`, the default of `always` for a rule that calls `now()`, the gate lines for `frozen` and `touched` on `now()`, the next patch number of the 0.2 line (Decision 14) with the test `core_version_requirement_frozen_now`, docs. *Slice 3 (parked, additive, size S to M each; only if the owner asks):* delete covered by `frozen` (a hook in `Unit::delete`, which holds the stored record; not planned, because Q61 was answered no for now (OR-35 f); if the owner ever asks again, with an erasure route in the same release and the platform team told first, as Q58 asks), a second hop, and a rule pre-flight in the guards of create and update so that `approve` refuses before the plan is spent. Not wanted by the platform team and therefore not planned: `old.` paths and an "unchangeable after create" flag (Q57), a per-rule bypass group (Q58). Slice 1 carries the first half because it needs no clock; the clock adds a second kind of non-determinism (the stamp) that deserves its own tests, and `frozen` goes with it because a poll can only be said to close once edits and moves out of it are refused.
@@ -1048,7 +1050,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
**Evidence.** A design, not a build. Read against `v0.2.0-r31` (7bb4304); the five claims that carry the most weight (the write paths through `Unit`, assertions on actions only, the language without paths, `deny_unknown_fields` on `ModelM`, `CORE_VERSION` against the workspace version) were checked again in the repository when this record was written, as were the lock set and the bundled-package counts (73 models, 40 with different read and write groups). Three adversarial reviews of the design (31 findings: 3 high, 14 medium, 14 low; all valid or valid in part, none refuted, all applied). The platform team's answers of 2026-10-28 (Q57, Q58) were relayed by the setup session and are not owner rulings. The full design note is kept outside the repository.
#### D89 Delete rules on references: refuse, cascade, set_null, and a refusal that does not reveal private rows
-**Status** PROPOSED (a design of 2026-10-28 for ask 11.6 of the platform team, a project that builds on obop; nothing is built; not ratified as binding), except nine points that are RATIFIED by OR-35 (the owner's answers of 2026-10-28 and 2026-10-29, relayed by the setup session, not quoted): the acceptance of the leaks that remain (Decision 9, part a), the human-approver rule for an agent's delete (Decision 5, part b), the interim audit sink (Decision 8, part c), the starting cap of 2,000 touched rows with a ceiling of 4,000 and the bench at both (Decision 7, part d), that `cascade` ships before D79's DELETE position exists (Decisions 2 and 12, part e), that the approver view hides an edge into a model the approver cannot read (Decision 5, part g), the amendment of MODULES.md 3.1 for patch releases, with the bundled packages kept at `^0.2` (Decision 15, part h), API fields and field descriptions in patch releases (Decision 15, part 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; Decision 15, part k). Two further points were settled by the lead session on 2026-10-28 with no objection from the setup session; they are lead decisions, not owner rulings, and stay PROPOSED: the delete refusal moves from a bare 400 to 409 `delete_blocked` with `code`, `hint` and `references` (slice 0b); and the mechanism of the version rule, in which each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line (for D89 slice 1 and slice 2) and the core is not raised to 0.3.0 for them (Decision 15, with D88 Decision 14; the owner ruled only the amendment, API fields and field descriptions, the generated texts of part (k) named above, and the bundled packages' `^0.2`, OR-35 h, j and k). A third lead decision, of 2026-10-29, applies to this record and to D88: the texts of part (k) may change in a patch release only where a decision record lists the change as a visible difference and the release notes list it; it is applied by analogy to part (h), the owner was not shown it for part (k), and it stays PROPOSED (stated in D88 Decision 14; Decision 15 of this record lists the differences it asks for). The approver of an agent's delete is shown the static cascade edges by a view that slice 2 builds, because none exists at r31 (Decision 5). **Amends** D49 (its "Rejected: cascading deletes" and the text of its refusal), D52 (one amendment line for the package format) and D58 (what a refusal may say: "the referrers named when a delete is refused"). **Touches, to be coordinated and not amended by this record alone** D76 (it plans `find_in`, which this record needs with a `limit`, and plans a test `referrers_message_identical` that would pin the message this record changes), D78 (Decision 10 plans `guards::referrers` calling `find`, and the automatic reference index), D79 (section 9 lists "a refused delete says that records the caller cannot see refer to it" among accepted residuals that it plans to pin by a test, which this record removes; its DELETE position does not exist yet, and OR-35 e lets `cascade` ship without it), D75 (history and the write context), D77 (immutability) and D88 (its model rules do not check the writes of a cascade, Decision 14). **Reversibility** a two-way door until a package or a client uses it: the default stays `refuse`, the key is additive and no stored data changes. A one-way door afterwards: the package key, the code `delete_blocked`, status 409 and the `references` body are public contract (D80). **Owner action** no numbered question is open. The choice that was put to the owner in the description of the earlier pull request, and not as a question of the register, whether the approver view of Decision 5 hides edges into models the approver cannot read or shows every declared edge by model name, was answered on 2026-10-29 (OR-35 g): it hides them. Q59 (the cap) and Q60 (who may delete before D79's DELETE position exists) were answered by the owner (OR-35 d, e). Of the seven questions in the design note, five were answered by the owner on 2026-10-28 (OR-35: the leaks that remain, the agent path, the interim audit sink, the cap, who may delete before D79), the core version in part on 2026-10-29 (OR-35 h, j and k; its mechanism stays a lead decision), and one was settled by a lead decision (409); the administrator-only read `delete_check` that the fourth question also asked about is not asked here and stays a parked slice.
+**Status** PROPOSED (a design of 2026-10-28 for ask 11.6 of the platform team, a project that builds on obop; nothing is built; not ratified as binding), except nine points that are RATIFIED by OR-35 (the owner's answers of 2026-10-28 and 2026-10-29, relayed by the setup session, not quoted): the acceptance of the leaks that remain (Decision 9, part a), the human-approver rule for an agent's delete (Decision 5, part b), the interim audit sink (Decision 8, part c), the starting cap of 2,000 touched rows with a ceiling of 4,000 and the bench at both (Decision 7, part d), that `cascade` ships before D79's DELETE position exists (Decisions 2 and 12, part e), that the approver view hides an edge into a model the approver cannot read (Decision 5, part g), the amendment of MODULES.md 3.1 for patch releases, with the bundled packages kept at `^0.2` (Decision 15, part h), API fields and field descriptions in patch releases (Decision 15, part 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; Decision 15, part k). Two further points were settled by the lead session on 2026-10-28 with no objection from the setup session; they are lead decisions, not owner rulings, and stay PROPOSED: the delete refusal moves from a bare 400 to 409 `delete_blocked` with `code`, `hint` and `references` (slice 0b); and the mechanism of the version rule, in which each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line (for D89 slice 1 and slice 2) and the core is not raised to 0.3.0 for them (Decision 15, with D88 Decision 14; the owner ruled only the amendment, API fields and field descriptions, the generated texts of part (k) named above, and the bundled packages' `^0.2`, OR-35 h, j and k, and, for the release of the field limits only, a patch per commit that widens the manifest and the tag form `v0.2.N-rM`, OR-37 b). A third lead decision, of 2026-10-29, applies to this record and to D88: the texts of part (k) may change in a patch release only where a decision record lists the change as a visible difference and the release notes list it; it is applied by analogy to part (h), the owner was not shown it for part (k), and it stays PROPOSED (stated in D88 Decision 14; Decision 15 of this record lists the differences it asks for). The approver of an agent's delete is shown the static cascade edges by a view that slice 2 builds, because none exists at r31 (Decision 5). **Amends** D49 (its "Rejected: cascading deletes" and the text of its refusal), D52 (one amendment line for the package format) and D58 (what a refusal may say: "the referrers named when a delete is refused"). **Touches, to be coordinated and not amended by this record alone** D76 (it plans `find_in`, which this record needs with a `limit`, and plans a test `referrers_message_identical` that would pin the message this record changes), D78 (Decision 10 plans `guards::referrers` calling `find`, and the automatic reference index), D79 (section 9 lists "a refused delete says that records the caller cannot see refer to it" among accepted residuals that it plans to pin by a test, which this record removes; its DELETE position does not exist yet, and OR-35 e lets `cascade` ship without it), D75 (history and the write context), D77 (immutability) and D88 (its model rules do not check the writes of a cascade, Decision 14). **Reversibility** a two-way door until a package or a client uses it: the default stays `refuse`, the key is additive and no stored data changes. A one-way door afterwards: the package key, the code `delete_blocked`, status 409 and the `references` body are public contract (D80). **Owner action** no numbered question is open. The choice that was put to the owner in the description of the earlier pull request, and not as a question of the register, whether the approver view of Decision 5 hides edges into models the approver cannot read or shows every declared edge by model name, was answered on 2026-10-29 (OR-35 g): it hides them. Q59 (the cap) and Q60 (who may delete before D79's DELETE position exists) were answered by the owner (OR-35 d, e). Of the seven questions in the design note, five were answered by the owner on 2026-10-28 (OR-35: the leaks that remain, the agent path, the interim audit sink, the cap, who may delete before D79), the core version in part on 2026-10-29 (OR-35 h, j and k; its mechanism stays a lead decision), and one was settled by a lead decision (409); the administrator-only read `delete_check` that the fourth question also asked about is not asked here and stays a parked slice.
**Source.** Ask 11.6 of the amendment of 2026-10-28 to the platform team's asks (kept outside this repository; it also names asks 6.3 erasure and retention, 2.2 a separate delete rule and 1.6 history and erase): a `ref` may declare what happens to the rows that point at its target when the target is deleted by someone who may delete it (refuse, delete them, or empty the reference), the engine applies it under its own authority in one transaction and audited, and a refusal does not reveal a row the caller cannot see. The platform's case: a read marker, a personal pin and a vote are private to the person who made them and point at the post they belong to; once anyone has read a post, its author, the moderators and the loader are refused a delete, and the refusal tells the author that a private row exists (measured on `v0.2.0-r26`). The design was made read-only against `v0.2.0-r31` (7bb4304) and reviewed adversarially three times (41 findings, all valid in some form, none refuted outright, all applied). Read with D40, D41, D48, D49, D52, D56, D57, D58, D70, D74, D75, D76, D77, D78, D79, D80 and D88.
@@ -1082,7 +1084,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
*14. Interplay.* D40 and D41: refusals and audit carry names and ids and never values; the attribute adds no field, so no new data class or group. D48: a ref is never governed, a cascade runs no action and assertions are not re-run (an emptied row may fail its next ordinary update). D49: amended as above. D52: package-format amendment and client keys. D56: effects already applied by a deleted row are not undone. D57: a gapless number blocks; unique is freed by deletes and guarded for patches by the gate. D58: the root is gated, dependents are not (the point of the ask), naming respects groups, rules, field readability and the path rule. D70: `bulk_create` holds the lock, so it serialises with deletes; there is no bulk delete command. D74: Decision 11. D75: Decisions 8 and 10. D76: `find_in` is its primitive; compiled row rules are not used for dependents. D77: Decision 10. D78: the automatic reference index makes `find_in` an index lookup. D79: the DELETE position is what will make "someone who may delete it" more than write group plus visibility (OR-35 e lets `cascade` ship before it). D80: no new command in slices 0 to 5; a one-way door once public. D88: the writes of a cascade (`Unit::patch` for `set_null`, `Unit::delete_if`) are engine-authority writes and are not checked by model rules, by design (D88 Decisions 4 and 13: `Unit::patch` is `merge_unchecked(.., Exempt::Cascade)`, and `Unit::delete_if` uses `Unit::delete`, which no rule covers): a rule refusal would reach a caller who only asked to delete a different record, and a failed cascade answers `delete_blocked`. A `set_null` can therefore leave a stored record that violates a rule (D88-gap-1, D88-gap-12); the gate of Decision 12 refuses `set_null` on a field that a rule of the registry being checked reads. Both records edit `refresh_lock_models` (the lock set is the union), the error table, `/api/meta` and the version number (each takes its own patch number, D88 Decision 14).
-*15. Compatibility and the core version.* No stored data changes. The default is `refuse`, so every existing delete is refused or allowed exactly when it was. *Visible differences, and the number each ships under.* Slices 0a and 0b widen nothing that the manifest parser accepts, so by the version rule they take no number of their own: their differences ship with whatever image they land in, and the release notes list them. Slice 0a: "records you cannot see" is gone; models the caller cannot read are no longer named; a human's delete scans once and not twice. Slice 0b (a lead decision, PROPOSED): the refusal is 409 `delete_blocked` with `code`, `hint` and `references` instead of a bare 400, which changes a documented body (`errors.rs` says legacy bodies are documented API; keeping 400 is a one-line alternative in the table row). Slice 1 takes the first of D89's two numbers and adds, for every ref field of every package, the key `on_delete` to `/api/meta` (`"refuse"` for a field that declares nothing) and a sentence to the MCP and OpenAPI field description (Decision 13), the change kind `field_on_delete`, and the plan advice of Decision 12 (D89-advice-3 fires on a default `refuse` edge from a model with `own`, `via` or `shared_with`, which bundled packages have, for example `documents_document`, so `module_plan.preview` prints a new advice line when such a package is installed); slice 2 takes the second and replaces the generated text of every `.delete` in the two places that build it (Decision 13), adds the dialog line D89-msg-4, the approval card, the REST route that reads a stored delete plan, the `security_event` kind `delete_cascade` and the reworded descriptions of that model (Decisions 5, 8 and 13). These are additive or descriptive and the release notes list them. What each rests on: the status and text of the refusal (slice 0b) and the changed refusal texts of slice 0a rest on the text or status of a refusal (OR-35 h); the stored-plan route rests on routes (OR-35 h); the key `on_delete` in `/api/meta`, the new body keys of the 409 `delete_blocked` (slice 0b) and the field-description sentence in OpenAPI and MCP rest on API fields and field descriptions (OR-35 j); the reworded generated description of every `.delete` rests on OR-35 k, which names every delete description among the generated texts that tell agents what each operation does and which part (j), meaning field descriptions, does not cover (the OpenAPI summary of delete is read by the record as within it, for the owner to correct; the OpenAPI summaries of `create`, `update` and `bulk_create` are not named by part (k) and are not changed); the reworded descriptions of `security_event` rest on OR-35 k, which names the descriptions of `security_event` wholesale (the model description, and the descriptions of the fields `kind` and `detail` that Decision 8 lists, which are also field descriptions within OR-35 j); the new upgrade-diff change kind `field_on_delete` (slice 1) and the new `security_event` kind `delete_cascade` (slice 2) are new values of existing API fields (the `kind` of a change row, `change_value` in `crates/kernel/obop-pkg/src/views.rs:267`, and the `kind` field of `security_event`, `crates/kernel/obop-pkg/src/installer.rs:2442`), additive and listed in the release notes, and no part of OR-35 names them: that is the record's reading, and the owner may rule otherwise; the plan advice, the dialogs and the approval card add to what the plan and the back office show and change no key, command or API field. MODULES.md section 3.1 said until the amendment accepted by the owner (OR-35 h and j, D88 Decision 14) that a patch bump never breaks the API; without it that sentence would be false for slices 0a and 0b. `CORE_VERSION` is `0.2.0` for every `v0.2.0-rN` tag, so `requires_core = "^0.2"` matches r31 and every older core that refuses the key, and a package that uses `on_delete` cannot be kept off older cores by `requires_core` while every image reports `0.2.0`. The version rule is the one of D88 Decision 14 (a lead decision of 2026-10-28, not an owner ruling, except what OR-35 h, j and k rule: the amendment of MODULES.md 3.1, without the listing condition for the texts of part (k), which is a lead decision of 2026-10-29 stated in D88 Decision 14, and that the bundled packages keep `^0.2`): no 0.3.0; each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line, in the order the commits land. For D89 these are slice 1 (the key `on_delete` parses; `cascade` and `set_null` are still refused while `ON_DELETE_ENGINE` is false) and slice 2 (the flip of `ON_DELETE_ENGINE`, which makes `cascade` and `set_null` parse). A package that writes only `on_delete = "refuse"` declares `requires_core = "^0.2.N"` with N the slice-1 number, and a package that uses `cascade` or `set_null` declares the slice-2 number. Why not 0.3.0, what the number does and does not do (on an older core the package fails at parse time, whatever `requires_core` says), the tag form `v0.2.N-rM`, the pins that the bump commit changes, and what is not reconciled with OR-32, D55 and MODULES.md section 3.1 are in D88 Decision 14 and are not repeated here. *The rollback hazard of this record.* An image rollback (D63) to a core without the key makes `Manifest::parse` refuse the stored manifest, `load_installed` puts the package in its `failed` list and does not register it, the models vanish from the registry, and deleting their target ignores their edges while the rows stay in the database; `requires_core` does not prevent it (`rollback()` does not read it); how start reports that list was not checked. The release drill of slice 1 (listed in the tests of slice 1) covers the rollback from the slice-1 image and slice 2 repeats it for `cascade`. Slice 1 adds the test `core_version_requirement_on_delete` (a package using `on_delete` declares `requires_core = "^0.2.N"`, N written in the test as the slice-1 number; `CORE_VERSION` satisfies it and `0.2.(N-1)` does not) and changes the pins listed in D88 Decision 14; slice 2 adds `core_version_requirement_cascade` the same way. The upgrade diff gets a new change kind `field_on_delete`, listed in the plan and not breaking; becoming `set_null` on a field that also becomes `required` is refused by the gate. The 77 `kind = "ref"` declarations in the 21 bundled packages that have them, and the built-in modules, change nothing in what is stored or in when a delete is refused (their meta, OpenAPI and MCP descriptions change as listed above), and no `requires_core` or dependency range of a bundled package changes.
+*15. Compatibility and the core version.* No stored data changes. The default is `refuse`, so every existing delete is refused or allowed exactly when it was. *Visible differences, and the number each ships under.* Slices 0a and 0b widen nothing that the manifest parser accepts, so by the version rule they take no number of their own: their differences ship with whatever image they land in, and the release notes list them. Slice 0a: "records you cannot see" is gone; models the caller cannot read are no longer named; a human's delete scans once and not twice. Slice 0b (a lead decision, PROPOSED): the refusal is 409 `delete_blocked` with `code`, `hint` and `references` instead of a bare 400, which changes a documented body (`errors.rs` says legacy bodies are documented API; keeping 400 is a one-line alternative in the table row). Slice 1 takes the first of D89's two numbers and adds, for every ref field of every package, the key `on_delete` to `/api/meta` (`"refuse"` for a field that declares nothing) and a sentence to the MCP and OpenAPI field description (Decision 13), the change kind `field_on_delete`, and the plan advice of Decision 12 (D89-advice-3 fires on a default `refuse` edge from a model with `own`, `via` or `shared_with`, which bundled packages have, for example `documents_document`, so `module_plan.preview` prints a new advice line when such a package is installed); slice 2 takes the second and replaces the generated text of every `.delete` in the two places that build it (Decision 13), adds the dialog line D89-msg-4, the approval card, the REST route that reads a stored delete plan, the `security_event` kind `delete_cascade` and the reworded descriptions of that model (Decisions 5, 8 and 13). These are additive or descriptive and the release notes list them. What each rests on: the status and text of the refusal (slice 0b) and the changed refusal texts of slice 0a rest on the text or status of a refusal (OR-35 h); the stored-plan route rests on routes (OR-35 h); the key `on_delete` in `/api/meta`, the new body keys of the 409 `delete_blocked` (slice 0b) and the field-description sentence in OpenAPI and MCP rest on API fields and field descriptions (OR-35 j); the reworded generated description of every `.delete` rests on OR-35 k, which names every delete description among the generated texts that tell agents what each operation does and which part (j), meaning field descriptions, does not cover (the OpenAPI summary of delete is read by the record as within it, for the owner to correct; the OpenAPI summaries of `create`, `update` and `bulk_create` are not named by part (k) and are not changed); the reworded descriptions of `security_event` rest on OR-35 k, which names the descriptions of `security_event` wholesale (the model description, and the descriptions of the fields `kind` and `detail` that Decision 8 lists, which are also field descriptions within OR-35 j); the new upgrade-diff change kind `field_on_delete` (slice 1) and the new `security_event` kind `delete_cascade` (slice 2) are new values of existing API fields (the `kind` of a change row, `change_value` in `crates/kernel/obop-pkg/src/views.rs:267`, and the `kind` field of `security_event`, `crates/kernel/obop-pkg/src/installer.rs:2442`), additive and listed in the release notes, and no part of OR-35 names them: that is the record's reading, and the owner may rule otherwise; the plan advice, the dialogs and the approval card add to what the plan and the back office show and change no key, command or API field. MODULES.md section 3.1 said until the amendment accepted by the owner (OR-35 h and j, D88 Decision 14) that a patch bump never breaks the API; without it that sentence would be false for slices 0a and 0b. `CORE_VERSION` is `0.2.0` for every `v0.2.0-rN` tag, so `requires_core = "^0.2"` matches r31 and every older core that refuses the key, and a package that uses `on_delete` cannot be kept off older cores by `requires_core` while every image reports `0.2.0`. The version rule is the one of D88 Decision 14 (a lead decision of 2026-10-28, not an owner ruling, except what OR-35 h, j and k rule: the amendment of MODULES.md 3.1, without the listing condition for the texts of part (k), which is a lead decision of 2026-10-29 stated in D88 Decision 14, and that the bundled packages keep `^0.2`; and OR-37 b, which accepted a patch per commit that widens the manifest and the tag form `v0.2.N-rM` for the release of the field limits only): no 0.3.0; each commit that widens what the manifest parser accepts takes the next patch number of the 0.2 line, in the order the commits land. For D89 these are slice 1 (the key `on_delete` parses; `cascade` and `set_null` are still refused while `ON_DELETE_ENGINE` is false) and slice 2 (the flip of `ON_DELETE_ENGINE`, which makes `cascade` and `set_null` parse). A package that writes only `on_delete = "refuse"` declares `requires_core = "^0.2.N"` with N the slice-1 number, and a package that uses `cascade` or `set_null` declares the slice-2 number. Why not 0.3.0, what the number does and does not do (on an older core the package fails at parse time, whatever `requires_core` says), the tag form `v0.2.N-rM`, the pins that the bump commit changes, and what is not reconciled with OR-32, D55 and MODULES.md section 3.1 are in D88 Decision 14 and are not repeated here. *The rollback hazard of this record.* An image rollback (D63) to a core without the key makes `Manifest::parse` refuse the stored manifest, `load_installed` puts the package in its `failed` list and does not register it, the models vanish from the registry, and deleting their target ignores their edges while the rows stay in the database; `requires_core` does not prevent it (`rollback()` does not read it); how start reports that list was not checked. The release drill of slice 1 (listed in the tests of slice 1) covers the rollback from the slice-1 image and slice 2 repeats it for `cascade`. Slice 1 adds the test `core_version_requirement_on_delete` (a package using `on_delete` declares `requires_core = "^0.2.N"`, N written in the test as the slice-1 number; `CORE_VERSION` satisfies it and `0.2.(N-1)` does not) and changes the pins listed in D88 Decision 14; slice 2 adds `core_version_requirement_cascade` the same way. The upgrade diff gets a new change kind `field_on_delete`, listed in the plan and not breaking; becoming `set_null` on a field that also becomes `required` is refused by the gate. The 77 `kind = "ref"` declarations in the 21 bundled packages that have them, and the built-in modules, change nothing in what is stored or in when a delete is refused (their meta, OpenAPI and MCP descriptions change as listed above), and no `requires_core` or dependency range of a bundled package changes.
*16. Slices, in build order* (effort S, M, L; "3 backends" means memory, SQLite and PostgreSQL; a test marked pin records behaviour that must not change). The order is 0a, 0b, 1, 2, then the D75 tracking package (`Dispatcher::register_crud` and the write context in `Dispatcher::run`) and the D77 guard check that `Unit` calls elsewhere, then 5; 3 may land with D76 and D78. Slice 4 measures the engine of slice 2, so slice 2 may merge before slice 4 with the cap and ceiling of OR-35 d as starting values; the documentation says that the time of a delete is unmeasured until slice 4 has run, and no release note or settings reference describes the numbers as measured before then. *Slice 0a: a true, neutral refusal for today's `refuse`, one scan per human delete (M).* No declaration and no wire change (status 400, legacy text). `Backend::find_in` with the default and its forwards; `referrers()` becomes the first half of the plan with `refuse` edges only; the nameable rule of Decision 9 (read group, row rule, field readability; the path rule is trivial at depth 1); `Actor::viewer`; no hidden marker; the guard checks references only for agents; the handler plans once; updates of the pinned texts, MODULES.md and the D49 and D79 texts. Tests: `refusal_never_names_a_model_the_caller_cannot_read` (leak 2, fails today), `refusal_never_says_cannot_see`, `neutral_refusal_is_byte_identical_for_one_and_thirty_hidden_referrers`, `visible_referrers_are_named_the_same_for_0_1_30_and_1500_hidden_ones`, `a_referrer_with_an_unreadable_ref_field_is_not_named`, `a_humans_delete_reads_each_referring_model_once` (counting backend; fails today), `an_agents_proposal_reads_each_referring_model_once_and_never_the_handler_twice`, `an_agent_proposal_with_only_hidden_referrers_is_accepted_and_refused_only_at_approval`, `the_refusal_at_approval_is_built_for_the_approver`, `an_admin_agent_and_a_plain_approver`, `find_in_agrees_across_backends`, and the pins `1500_visible_referrers_are_still_named`, `two_reference_fields_of_one_model_count_the_record_once`, `invisible_root_and_missing_id_answer_identically`, `admin_gets_every_referrer`. *Slice 0b: `delete_blocked` 409 with `code`, `hint`, `references` (M).* The table row, the strict tail splitter, the CLI line, OpenAPI 409, the MCP error body, the back office reading `code` and `references`, `serverMessage.ts` rules, D89-msg-1 to D89-msg-3 in seven languages. Tests: `rest_answers_409_with_code_hint_references`, `mcp_error_result_carries_the_same_body`, `cli_prints_the_debug_form_of_the_head_without_the_tail`, `each_row_maps_as_the_table_says_has_the_tail_exception`, `a_forged_tail_cannot_change_code_or_error`, `the_table_says_what_the_plan_says` and `the_table_is_well_formed` updated, `openapi_declares_409_delete_blocked_and_state_conflict`, the web tests for `code` and `references` with the old text as fallback, `i18n:check` green. *Slice 1: declaration, gate, meta (M).* Parser, `OnDelete`, `FieldDef.on_delete`, the registry gate, the manifest checks, `ON_DELETE_ENGINE` false, `FieldInfo.on_delete`, MCP and OpenAPI field description, plan advice, the `field_on_delete` change kind, the D52 and MODULES.md amendments (and, if D89 lands before D88 slice 1, the conforming edit of OR-32, D55, the Alignment rules and MODULES.md line 21, D88 Decision 14), the module builder field, the version bump with the pins of D88 Decision 14 and the test `core_version_requirement_on_delete`; the engine treats every policy as `refuse`. Tests: `on_delete_only_valid_for_ref`, `unknown_on_delete_value_is_refused`, `cascade_and_set_null_are_refused_until_the_engine_has_them`, `set_null_on_a_required_field_is_refused`, `set_null_on_a_unique_with_member_is_refused`, `set_null_on_an_access_field_or_one_read_by_the_public_field_expression_is_refused`, `set_null_on_a_field_a_model_rule_reads_is_refused` (vacuous until D88 is built, required once it is), `cascade_into_a_gapless_at_create_model_is_refused`, `meta_carries_on_delete_for_ref_fields_only`, `diff_reports_field_on_delete_not_breaking`, `plan_preview_lists_the_advice`, `mcp_tool_schema_and_openapi_describe_the_rule`, `obop_modules_check_prints_the_gate_messages`, `all_bundled_packages_install_unchanged` (a pin: it passes before and after), the release drill of D88 Decision 14 for a package with `on_delete = "refuse"` (not an automated test: start the previous image on a database with such a package and record what happens), and Playwright `module_builder_saves_on_delete` and `plan_dialog_shows_on_delete`. *Slice 2: plan and apply (L).* `engine/delete.rs`, `Unit::patch`, `Unit::delete_if`, `Unit::commit_typed`, `lock_models`, the approver view of Decision 5 (the read of a stored delete plan and the approval card, built before the flip), the audit entries, the observer fan-out, `Catalog::detach_records`, `OBOP_DELETE_MAX_ROWS` (default 2,000), the budgets, the `Guarded` set, the generated description, the `state_conflict` mapping, the dialog line D89-msg-4, and the flip of `ON_DELETE_ENGINE` with its own patch number of the 0.2 line (Decision 15) and the test `core_version_requirement_cascade`; it works before the D75 tracking package and the D77 guard check. Tests (3 backends unless noted): `cascade_deletes_other_peoples_private_rows_with_the_post` (the ask: marker, pin and vote of three people hidden by `access.own`; the author deletes; all gone in one transaction; the author cannot read the audit entries and an administrator can), `set_null_empties_the_reference_and_changes_nothing_else`, the pin `refuse_declared_still_refuses_and_changes_nothing`, `cascade_is_transitive_and_the_root_goes_last_in_a_stable_order`, `a_diamond_counts_each_row_once`, `a_self_referencing_cascade_terminates`, `a_refuse_edge_from_a_row_the_plan_deletes_does_not_block`, `a_refuse_edge_from_a_row_the_plan_keeps_blocks_and_changes_nothing`, `a_numbered_dependent_blocks_neutrally_and_changes_nothing`, `a_blocker_behind_a_hidden_intermediate_is_not_named`, `a_cap_cause_answers_neutral_with_empty_references_whatever_was_read_before` (the cap set low for the test), `a_plan_of_exactly_the_cap_succeeds_and_one_more_is_refused`, `a_chain_deeper_than_the_level_budget_is_refused_neutrally_and_counts_find_in_calls`, `an_agent_proposal_is_not_a_probe_for_the_cap`, `the_refusal_at_approval_names_nothing_the_approver_cannot_list`, `find_in_never_returns_more_than_limit`, `a_repointed_child_is_not_deleted_and_the_batch_changes_nothing`, `a_vanished_planned_row_answers_state_conflict_without_a_step_number`, `a_refused_batch_changes_nothing_and_says_applied_none`, `connection_lost_during_the_batch_says_unknown_and_the_root_is_the_oracle`, `lock_models_contains_every_child_of_a_non_refuse_edge`, `a_child_created_during_a_delete_is_never_left_dangling` (two threads, one dispatcher) and its PostgreSQL twin on a second dispatcher with `set_shared`, `a_dependent_with_an_extra_delete_guard_blocks_a_cascade`, `no_generated_command_guard_of_the_dependent_runs`, `the_audit_entries_name_caller_approver_and_every_id_in_the_same_batch`, `an_aborted_batch_leaves_no_audit_entry`, the pins `a_plain_delete_writes_no_audit_entry` and `a_refused_delete_writes_nothing`, `observers_are_told_about_each_cascaded_row_after_commit`, `the_answer_has_no_cascade_key`, `blobs_of_cascaded_rows_are_released_in_one_catalogue_pass`, `delete_description_mentions_the_rules`, `final_stores_equal_across_backends`, `approval_card_lists_the_static_cascade_edges` (REST read of the stored plan and Playwright card: the edges of a post are listed, no row id and no count appears, an edge into a model the approver cannot read is absent), `approval_view_of_a_model_without_cascade_edges_lists_none`, `the_audit_entries_are_linked_by_batch_and_chunk` (two deletes in one second, every chunk `i/n` present, the `chunks` of the summary equals `n`), `set_null_into_a_rule_model_is_applied_unchecked` (a pin of Decision 14, once D88 is built) and `cascade_writes_are_named_exempt_and_unchecked` (vacuous until D88 is built: `Unit::patch` is `merge_unchecked(.., Exempt::Cascade)`, the lint of D88 lists that call and no other caller of `Exempt::Cascade`, and `Unit::delete_if` reaches only `Unit::delete`), the release drill of D88 Decision 14 repeated for `cascade`, and Playwright `delete_dialog_lists_the_cascade_models`. *Slice 3: PostgreSQL `find_in` SQL and the reference index (S to M; may land with D76 and D78).* `find_in_uses_the_ref_index` (EXPLAIN, 200,000 rows) and `pg_find_in_matches_the_default_on_hostile_values`. *Slice 4: the bench (S).* An `#[ignore]` test on SQLite and PostgreSQL: cascades of 2,000 and 4,000 dependents (500 and 1,000 for comparison), a 100,000-row referring table with and without the index, a 500-deep chain; it prints statements, wall time and peak memory, confirms the budgets or proposes other ones, reports whether 2,000 and 4,000 hold (the record reads a result that contradicts them as a new question for the owner; OR-35 d does not say), and decides whether the planner should read an edge once when `find_in` is a scan; the time of a delete stays labelled unmeasured in code and documentation until it has run. *Slice 5: history and guards, after the D75 tracking package and the D77 guard check (M).* `batch_id`, `via.path = "cascade"`, and the `security_event` chunks stop listing the ids of rows whose model has history on (the summary line stays, and a model with history off keeps its ids in the chunks); tests `history_entries_name_the_caller_the_batch_and_cascade_path`, `a_child_without_history_keeps_its_ids_in_the_audit_chunks`, `a_frozen_dependent_refuses_the_batch_neutrally_on_every_backend` and `obg01_detail_is_never_echoed`. *Slice 6 (only if the bench or the platform team asks): escalation.* A chunked operator drain for a post over the cap (the team accepts this in place of atomicity), a storage-side set operation, a fence for `refuse` referrers and an administrator-only read `delete_check` (the full plan, nothing written; it would reveal counts, so it is administrator-only because administrators see every row); not designed here beyond this.
@@ -1181,7 +1183,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
**Why.** The owner's ruling, and ROADMAP 4.5's finding that three items alone used 78% to 96% of W1 capacity at 6 to 10 FTE. **Superseded alternative.** R20 s9.3's seven-app loop, including purchase orders, payments providers, portal, six view types, Peppol through a partner, QuickBooks and the extension platform cut of R24 s11 (26 to 34 person-months) in W1 (former D25 and D26 W1 content). **Rejected.** Moving dates; a hosted on-ramp (R20's hosted Free plan; the sandbox of D83 is not one); keeping R20's list and hoping. **Consequences.** W1 ships with a thinner UI (list, form, kanban, search and the accounting views) and complete CLI, API and MCP surfaces for what it contains (D80); services firms wait for the services variant in W3; a light-trade firm that needs purchase orders waits for W2. With X3 in W3 (2028-07 onward), the Wasm host should start on the wasmtime LTS then current (v60, about 2027-08-20) instead of v48, whose support ends about 2028-08; D11's v48 pin then covers only the internal spike (ROADMAP 9.3 item 6; an ADR amends D11 when X3 starts). Locales: en and de are the W1 commitment; the other target locales of D34 arrive as community translations that pass review (Proposed, an amendment of D34's W1 reading). **Evidence.** OR-13, OR-15; R20 s9.3, R23 D, ROADMAP 4.5.
#### D87 Native apps (React Native clients)
-**Status** RATIFIED (OR-34) for parts (a) to (e) of the ruling: React Native (Expo) apps may be clients, the mobile-toolchain exception, manual start and stop as the baseline, Transistorsoft not the default, Expo SDK 57 for now (amended on 2026-10-28: SDK 58 if its packages pass the cooldown when the app's build starts, otherwise SDK 57 until the first clean point). The scope limits that came with the proposal (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, visible recording, the `expo-location` default path and its fallback, the reason given for excluding Transistorsoft and the remark that an optional adapter is not forbidden, the rule that Apple and Google accounts, store listings and publishing are the owner's alone, and the technical design below (the mobile sign-in class, the `device` model, the device outbox, the `mileage` package, the privacy points, the repository layout) are PROPOSED until built. **Reversibility** two-way door while nothing is published (the exception, the library and the SDK can change); once an app is on a store, its application id, signing key and permission declarations are hard to change. **Owner action** yes: Q37 to Q43. The proposal is that Apple and Google developer accounts, store listings and publishing are the owner's alone (Q37).
+**Status** RATIFIED (OR-34) for parts (a) to (e) of the ruling: React Native (Expo) apps may be clients, the mobile-toolchain exception, manual start and stop as the baseline, Transistorsoft not the default, Expo SDK 57 for now (amended on 2026-10-28: SDK 58 if its packages pass the cooldown when the app's build starts, otherwise SDK 57 until the first clean point). RATIFIED (OR-36, 2026-10-29): every native app, the Mileage app included, works on both Android and iOS; iOS is no longer deferred. The scope limits that came with the proposal (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, visible recording, the `expo-location` default path and its fallback, the reason given for excluding Transistorsoft and the remark that an optional adapter is not forbidden, the rule that Apple and Google accounts, store listings and publishing are the owner's alone, and the technical design below (the mobile sign-in class, the `device` model, the device outbox, the `mileage` package, the privacy points, the repository layout) are PROPOSED until built. **Reversibility** two-way door while nothing is published (the exception, the library and the SDK can change); once an app is on a store, its application id, signing key and permission declarations are hard to change. **Owner action** yes: Q37 to Q43. The proposal is that Apple and Google developer accounts, store listings and publishing are the owner's alone (Q37).
**Decision.** Owner: "React Native (Expo) allowed as an obop client with the mobile-toolchain exception; manual start and stop as the baseline, auto-stop only after legal sign-off; Transistorsoft excluded as the default; Expo SDK 57 for now." (given in chat to the main session, relayed to the lead session on 2026-10-27; OR-34). *Permission (OR-34 a RATIFIED; the scope limits Proposed).* React Native apps built with Expo are allowed as clients of the same REST and OpenAPI surface that the web back office uses (D31, D80). The proposal limits them to first-party apps and to cases where a PWA cannot do the job, that is where a background capability is needed (Proposed). Recording a trip while the phone is locked is the first such case (from general knowledge, not tested here: a browser page does not keep receiving location updates in the background). Also Proposed: the PWA stays the default client, Tauri 2 stays the shell of POS and desktop (D37), and Tauri mobile is not pursued. An app has no private backdoor: it calls REST and the OpenAPI document, signs in through OAuth (D64) and meets the same checks of the one Dispatcher as every other client; a capability it needs exists first as a typed command in the CLI, the API and MCP (D80). The first app is Mileage: a `mileage` package (trips, points, a review state machine) and an Expo app that keeps recording a trip while the phone is locked or the app is in the background. The specification is [spec/mobile.md](spec/mobile.md). "Native app" in this record means a native mobile app; it is unrelated to the package kind `native` of D32.
@@ -1197,26 +1199,28 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
*Location data (PROPOSED; not legal advice).* Location is personal data and a new processing location for the impact assessment. Raw points are minimised (thinned or deleted a stated number of days after a trip is confirmed; the distance and a simplified route stay); no third-party geocoder or map tiles by default (Q39); every read of a route is audited; recording is active only while the driver started it; the server, not the client, computes and validates the distance. A new data class `location` is Proposed (Q40). Retention also covers the encrypted device outbox and the D75 change history of the points. Details are in spec/mobile.md 5.
-*Limits (PROPOSED).* No Apple or Google account is used, no store listing is made and nothing is published without the owner (Q37). Tests use synthetic data only. Android comes first because it can be built on the Windows development machine; iOS comes second, when a Mac is available (Xcode runs on macOS only; general knowledge).
+*Platforms (RATIFIED, OR-36).* Every native app, the Mileage app included, works on both Android and iOS (the owner: "should work on both android and ios"; the words are quoted in full in the OR-36 row). iOS is no longer deferred. An iOS build uses `expo prebuild` and Xcode on a Mac (Xcode runs on macOS only; general knowledge), and the owner has a Mac mini connected to the same network (OR-36, his words). No date is promised.
+
+*Limits (PROPOSED).* No Apple or Google account is used, no store listing is made and nothing is published without the owner (Q37). Tests use synthetic data only.
*Delivered by the mobile sign-in of 2026-10-28 (docs/spec/mobile.md 8.5):* the "mobile" client class of `obop-oauth` with its redirect rules and the `/api` audience, the `device` model with `device.revoke`, the revocation of devices by a credential change and an offboarding, the Devices table in Customize, Security. *Delivered by the Expo spike of 2026-10-27 (docs/spec/mobile.md 8.3):* `web/packages/trip-core` (one distance function with tests) and `web/apps/mileage-app` (an Expo SDK 57 app with one screen), built for Android on a Windows machine and run on an emulator, with Metro and pnpm's symlinked `node_modules` verified. *Not delivered:* the rest. There is no sign-in helper in the client package, no `mileage` package, no location recording, no push, and nothing has been tried on a real phone or on iOS. Not verified: Apple's documentation (the iOS rules are from general knowledge); Expo's iOS behaviour with `CLBackgroundActivitySession`; whether a foreground service started from a visible app, or a trip recording of hours that a driver starts, avoids the Google Play background-location declaration (the policy text was read and does not say); whether `expo-location` works on Android without Google Play services (Q43); an Android build on Linux or macOS, with a JDK other than 17 or for all four ABIs; the Docker image build with the Expo packages in the workspace; the accessibility class of `expo-secure-store` items read while the phone is locked; the Transistorsoft price; the maintenance of self-hosted Expo update servers; the Expo release dates (seen in a search result); how to encrypt an `expo-sqlite` database; the Expo documentation read was that of SDK 58 and SDK 57 is assumed to behave the same.
**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.
+**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` and merged as PR 33 on 2026-10-29, squash 32a0758, tagged `v0.2.1-r1`, with the owner's approval of the merge), except two points that are RATIFIED by OR-37 (the owner's choice of the recommended option of two questions of the lead on 2026-10-29, relayed by the setup session; not quoted): (a) the two default lengths and the two caps of Decision 3, with the refusal of writes above the default (Decision 9 (a)); (b) the version effect of Decision 9 (the keys are a patch release under OR-35 h, `CORE_VERSION` 0.2.1, `^0.2.1` for a package that uses them, the bundled packages `^0.2`, the tag `v0.2.1-r1`, a patch per commit that widens the manifest, the tag form `v0.2.N-rM`); the rest is 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** none open. The two points that this record raised for the owner (the numbers of Decision 3; whether a patch release may add a refusal that OR-35 h does not name, Decision 9) were settled by OR-37 on 2026-10-29, the second so far as Decision 9 (a) goes. They were not registered as questions in section 8, the question counts of this document do not include them, and K43, which tracked both, is closed. Decision 9 (c), (d) and (e) are named by no ruling and stay listed there as visible differences.
**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.
+**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 four numbers below are RATIFIED by OR-37 a, and so is the refusal of writes above the default, Decision 9 (a); 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 (the owner chose them as the recommended option, OR-37 a; the reasons are the lead's: 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; (a) is the refusal that the owner ruled in OR-37 a (writes above the default are refused for every existing package and built-in field that declares nothing, reads and stored rows never touched), while (d) and (e) are named by neither OR-35 h nor OR-37, so this record lists them as visible differences, does not claim that a ruling 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 and, for this release, OR-37 b): 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), and the tag after the merge is `v0.2.1-r1` (tags are made by hand; the tag was made at the merge of 2026-10-29). The owner accepted exactly this in OR-37 b: the keys as a patch release under OR-35 h, 0.2.0 to 0.2.1, `^0.2.1`, the bundled `^0.2`, the tag `v0.2.1-r1`, a patch per commit that widens the manifest and the tag form `v0.2.N-rM`; no other part of the mechanism was put to him. The record's earlier plan, that if D88 or D89 landed first this would take the next number, did not apply; 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 are those of 0.2.1. 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 before; done by PR 35.* 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 (PR 33) was not one of the four and did not make that edit (OR-32 and D55 are ruled); this record said that the first of the four to land should, or the lead should say that this one does. The lead said on 2026-10-29 that it be made now, and PR 35 (the docs of OR-36 and OR-37) made it, exactly as far as D88 Decision 14 allows: OR-32 and D55 keep their wording and each gets one clause (from the first patch bump the build number is 0.2.N, 0.2.1 since the merge of PR 33), and the sentence of the Alignment rules is corrected the same way. The condition of Decision 14, that it is made only if the version rule stands, holds for this release: the owner accepted the mechanism for it (OR-37 b) and `CORE_VERSION` is 0.2.1. No ruling's content changed. MODULES.md line 21 and the D79 sentence were already corrected by PR 33. D88 Decision 14 names no other place and says of D89 that the later record then has nothing to do, so the sentences of D88 and D89 about the code reporting 0.2.0 (D88 Decision 14 and its built-today list, D89 Decision 15) were not edited: they state the premise of those designs, which are read against r31 (`v0.2.0-r31`). 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.
+**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 (the merge of PR 33 was approved on 2026-10-29, OR-37 c; 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 and the version effect; closed by OR-37), 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) were settled by OR-37 on 2026-10-29, the second so far as Decision 9 (a) goes; nothing else of D90 is ruled.
**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).
@@ -1327,7 +1331,7 @@ Removed with the rulings: the pooled Free tier (D6), the own Peppol access point
| Family | Meaning in this register | Defined in |
|---|---|---|
| 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) |
+| OR-1 to OR-37 | 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 to OR-37) | "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" above (OR-37 is the last row of the second table) |
| Q1-Q61 | Owner questions | Section 8 |
| F1-F6 | FIXED owner constraints | Section 1 |
| I-1 to I-4 | Interpretations of F1 and F2 (RATIFIED) | Section 1 |
@@ -1377,7 +1381,7 @@ Exit criteria are correctness and adoption criteria that a very small team contr
| W3 | Breadth | 2028-07-01 to 2029-06-30 | The services variant first, then in demand order and each with a design partner: the extension platform (X3, SDK, compat, F2, the X4 app model), stock extensions, bank feeds, Xero and ERPNext importers, `customize` models and layout editor, T1 `hr`, `expenses`, `documents`, `delivery`, assets; `l10n_pl` (PL pack stable 2028-08-31, Proposed, slips to W4 if W2 overran); the April 2029 series on 2029-04-17 (numbering a placeholder; cadence Proposed, Q28; an October 2028 slot on 2028-10-17 only if Q28 keeps two series a year); T2 apps (`mrp`, `pos` first); F3; custom domains; payroll engine; registry stage 2 on demand and only after an owner decision that amends D83 | At least two T2 apps stable (golden cases, proptests and a design partner each); parity test still green; every shipped pack passes its oracles; advisories handled inside the D85 window |
| W4 | Ecosystem | 2029-07-01 onward | The April 2030 series on 2030-04-16 (numbering a placeholder; cadence Proposed, Q28; an October 2029 slot on 2029-10-16 only if Q28 keeps two series a year); verticals; public-sector track once the EU strategy is law | Governed by the release cadence, not an exit date |
-Alignment rules. Scope moves, dates do not (D86). Release labels: the code's core version is already 0.2.0 (D55) and the tags v0.2.0-r1 onward mark the build, so the 0.x labels of this table are product milestones with Proposed labels, and the code's `0.2.0` and `v0.2.0-rN` tags are the running build number until the first milestone is announced (OR-32; the dates stay Proposed under Q8). The 0.5 public beta carries no commercial offer; no plan, price or SLA exists (D2). The free hosted sandbox opens with it (D83, OR-20) and carries no service level; it is an operating duty outside the W1 cut, and its opening is not protected by the slip order of 5.3 (Q35). The vulnerability-handling runbook of D17 is drilled by 2027-06-01, before the public beta. 1.0 falls after the CRA main obligations date (2027-12-11); whether they reach a free project is Q5. Outbound email in W1 uses the operator's SMTP relay or a provider API; the OAuth-only mailbox connectors of D39 (Gmail API, Microsoft Graph) land with the inbound gateway in W2, so the EWS shutdown of 2027-04-01 is not a W1 dependency.
+Alignment rules. Scope moves, dates do not (D86). Release labels: the code's core version is already 0.2.0 (D55) and the tags v0.2.0-r1 onward mark the build, so the 0.x labels of this table are product milestones with Proposed labels, and the code's `0.2.0` and `v0.2.0-rN` tags are the running build number until the first milestone is announced (OR-32; from the first patch bump the build number is 0.2.N, which is 0.2.1 since the merge of PR 33 (tag `v0.2.1-r1`, 2026-10-29), D88 Decision 14; the dates stay Proposed under Q8). The 0.5 public beta carries no commercial offer; no plan, price or SLA exists (D2). The free hosted sandbox opens with it (D83, OR-20) and carries no service level; it is an operating duty outside the W1 cut, and its opening is not protected by the slip order of 5.3 (Q35). The vulnerability-handling runbook of D17 is drilled by 2027-06-01, before the public beta. 1.0 falls after the CRA main obligations date (2027-12-11); whether they reach a free project is Q5. Outbound email in W1 uses the operator's SMTP relay or a provider API; the OAuth-only mailbox connectors of D39 (Gmail API, Microsoft Graph) land with the inbound gateway in W2, so the EWS shutdown of 2027-04-01 is not a W1 dependency.
### 5.3 W1 cut: necessary-inside calls and slip order (D86)
@@ -1516,7 +1520,7 @@ Do not repeat these superseded statements. The first block is technical and date
- **RATIFIED with the closure of Q10.** I-1, the D24 renames and the X and L prefixes were recorded as ratified with the closure of Q10, a basis the owner never gave for them (Q10 was closed by OR-8 and OR-9): they are RATIFIED by the owner on 2026-10-01 (OR-14). Write "RATIFIED (OR-14)". Q10 is closed by OR-8, OR-9 and OR-14, and ENGINEERING Q-E11 is still not covered by it. Rust 1.99.x adoption is a mechanical consequence of D23, not a ruling.
- **W1 "shape RATIFIED, calls PROPOSED".** The in and out calls of 5.3 are RATIFIED (OR-15); only the slip order, its checkpoints and the landing waves of displaced items (except the services variant, W3) remain Proposed. Q8 no longer asks for the W1 calls.
- **Q32 by 2027-03-31.** Q32 is decided at W2 (2027-10-01 to 2028-06-30), with the default of no publication until then (OR-19).
-- **Counts and answer dates of closed questions.** "The thirteen rulings", "OR-1 to OR-13", "Q1 to Q33" and "Q22 to Q33", "25 of 32 owner questions open", "seven closed questions (Q1, Q3, Q4, Q7, Q10, Q13, Q19)", and the answer dates of Q23 (2027-09-30), Q27 (2027-06-01), Q29 (2026-12-15), Q30 (2027-03-31), Q31 (2026-10-30) and Q32 (2027-03-31): there are now 35 rulings (OR-1 to OR-35) and 61 questions (Q1 to Q61); 21 are closed (Q1, Q3, Q4, Q7, Q10, Q13, Q15, Q17, Q19, Q23, Q27, Q29, Q30, Q31, Q33, Q42, Q57, Q58, Q59, Q60, Q61) and 40 are open, reduced or deferred (21 + 40 = 61). The new answer dates are Q34 2027-05-04, Q35 2027-03-31, Q36 2027-05-04 and Q32 at W2; Q37 to Q56 carry the event before which each is needed, not a calendar date; Q57 to Q61 were closed on the day they were put.
+- **Counts and answer dates of closed questions.** "The thirteen rulings", "OR-1 to OR-13", "Q1 to Q33" and "Q22 to Q33", "25 of 32 owner questions open", "seven closed questions (Q1, Q3, Q4, Q7, Q10, Q13, Q19)", and the answer dates of Q23 (2027-09-30), Q27 (2027-06-01), Q29 (2026-12-15), Q30 (2027-03-31), Q31 (2026-10-30) and Q32 (2027-03-31): there are now 37 rulings (OR-1 to OR-37) and 61 questions (Q1 to Q61); 21 are closed (Q1, Q3, Q4, Q7, Q10, Q13, Q15, Q17, Q19, Q23, Q27, Q29, Q30, Q31, Q33, Q42, Q57, Q58, Q59, Q60, Q61) and 40 are open, reduced or deferred (21 + 40 = 61). The new answer dates are Q34 2027-05-04, Q35 2027-03-31, Q36 2027-05-04 and Q32 at W2; Q37 to Q56 carry the event before which each is needed, not a calendar date; Q57 to Q61 were closed on the day they were put.
## 8. Open questions for the owner
@@ -1623,7 +1627,7 @@ All paths are relative to this file.
| 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) |
-| Rulings | The owner's 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), recorded in "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" above; they outrank every research note |
+| Rulings | The owner's 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 to OR-37), recorded in "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" above; they outrank every research note |
| Licence files | [../LICENSE](../LICENSE) (Apache-2.0) and [../NOTICE](../NOTICE) |
| CLI | [spec/cli.md](spec/cli.md) (the grammar and output conventions of D80 and Q22; the detail is Proposed until Q22 is answered) |
| Names | [CONVENTIONS.md](CONVENTIONS.md) (spelling of cross-document names; subordinate to this register) |
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index ada7ba32..82c78042 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 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) | - |
+| **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 to OR-37 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] |
@@ -191,7 +191,7 @@ flowchart TB
| **MRR** | Monthly recurring revenue, a term of the `subscriptions` module only (T2, W3): an append-only ledger records each movement (new, expansion, contraction, churn, reactivation) and emits `MrrMovementRecorded`. It measures the customer's own subscription business; the software has no revenue model, so MRR is no project metric (OR-2, OR-6). | [spec/sales-crm 4.5](spec/sales-crm.md#45-subscriptions-subscriptions) | Subscription MRR reports in Odoo Enterprise; the ledger goes beyond them |
| **MT940** | The older SWIFT text format for bank account statements. It is imported in W1 by `bank` beside camt.053. | [spec/finance 4.5](spec/finance.md#45-payments-and-bank-reconciliation); [LOCALIZATION 2.4](LOCALIZATION.md#24-bank-formats) | Statement import is not in the Community 20.0 addons list; presumably Enterprise (inferred) |
| **Named action** | A permission written `.`, such as `stock.validate`, `sales.price_override` or `hr.correct`, granted by rows of a permission group. It is a permission, not a group; the standard operations are `read`, `write`, `create`, `delete` and `export`. | [CONVENTIONS 4](CONVENTIONS.md#4-permission-group-registry) | - |
-| **Native app** | A mobile app built with React Native and Expo that is a client of the same API as the web back office. React Native (Expo) apps are allowed as obop clients (OR-34); the proposal limits them to first-party apps and to cases a PWA cannot serve (D87, Proposed). The first is Mileage. Not the package kind `native` (D32) and not a Tauri shell. | [DECISIONS D87](DECISIONS.md#d87-native-apps-react-native-clients); [spec/mobile](spec/mobile.md) | Odoo mobile apps (Enterprise) |
+| **Native app** | A mobile app built with React Native and Expo that is a client of the same API as the web back office. React Native (Expo) apps are allowed as obop clients (OR-34) and every native app works on both Android and iOS (OR-36); the proposal limits them to first-party apps and to cases a PWA cannot serve (D87, Proposed). The first is Mileage. Not the package kind `native` (D32) and not a Tauri shell. | [DECISIONS D87](DECISIONS.md#d87-native-apps-react-native-clients); [spec/mobile](spec/mobile.md) | Odoo mobile apps (Enterprise) |
| **NOTIFY** | Forbidden in business transactions, because the commit takes a cluster-wide lock and LISTEN is per database; only `obop-bus-notifier` may call it, enforced by CI (D7). Wake-up ladder: in-process `tokio::sync::Notify`, a coalescing notifier (at most 10 per second per node), then polling at 250 ms to 2 s. | [ARCHITECTURE 6.6](ARCHITECTURE.md#66-outbox-bus-database-and-wake-ups-d7); [DECISIONS D7](DECISIONS.md#d7-realtime-and-queue-signalling) | `bus.bus` plus `pg_notify` [R02 s12] |
### 2.9 O
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index a4aec89e..af9db11f 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -224,7 +224,7 @@ 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) |
+| 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) were proposals and are RATIFIED by OR-37 a (2026-10-29, the owner's choice of the recommended option): 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`. 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 OR-37 a rules it (writes above the default are refused for every existing package; stored rows and reads are never touched). 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 were cheapest to change before that release, which is `v0.2.1-r1` (PR 33, merged 2026-10-29). The two owner points of D90 (the four numbers; whether a patch release may add a refusal where there was none) were not entries of the question register in DECISIONS.md section 8 and are not in its counts; OR-37 settled them (a: the numbers and the refusal above the default; b: the version effect, the keys as a patch release, 0.2.1, `^0.2.1`), and the other visible differences of D90 Decision 9 ((c), (d), (e)) are named by no ruling | done (OR-37, 2026-10-29); lowering a number later stays the hazard described above |
| 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 |
diff --git a/docs/MODULES.md b/docs/MODULES.md
index b5342279..c8a4e31d 100644
--- a/docs/MODULES.md
+++ b/docs/MODULES.md
@@ -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, 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.
+Proposed (the version mechanism, a lead decision of 2026-10-28, not an owner ruling except what OR-37 b accepts below; D88 Decision 14, D89 Decision 15; not ratified otherwise): 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 (tag `v0.2.1-r1`): the field limits `max_length`, `min_length`, `min` and `max` (3.3, D90) need `requires_core = "^0.2.1"`, and the next number is for whichever of D88 and D89 lands next. The owner accepted exactly this for the field limits (OR-37 b, 2026-10-29): the keys are a patch release under OR-35 h, `CORE_VERSION` goes from 0.2.0 to 0.2.1, a package that uses them declares `^0.2.1`, the 23 bundled packages stay `^0.2`, the tag is `v0.2.1-r1`, and the mechanism is a patch per commit that widens the manifest with the tag form `v0.2.N-rM`; no other part of the mechanism was put to him. 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, and the owner ruled it (OR-37 a): writes above the default are refused for every existing package and built-in field that declares nothing, and reads and stored rows are never touched.
### 3.2 `[[model]]` (any number)
@@ -301,8 +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_length`, `min_length` | integer | `max_length`: 2,048 for `text`, 100,000 for `longtext`; no `min_length` | Only for `text` and `longtext` (D90; the key names are Proposed, the numbers and the core version are ratified by OR-37; 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 ratified (OR-37 a). 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, OR-37 b). 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. |
@@ -652,8 +652,8 @@ 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, 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"` |
+| `text` | string | One line, at most 2,048 characters unless the field declares a `max_length` (up to 10,000; D90, OR-37 a). `"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, OR-37 a). `"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"` |
diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md
index 4ba3e791..3204d609 100644
--- a/docs/PRODUCT.md
+++ b/docs/PRODUCT.md
@@ -14,7 +14,7 @@
- **Cost:** nothing for the software. The whole project is Apache-2.0 and completely free to use and to self-host (OR-1, OR-2): no plans, no paid list and no per-user price. The one planned paid thing is a hosting service run by SpaceCorps on dedicated Sliplane servers (OR-22): planned, not yet offered or charged. Apart from that SpaceCorps hosts nothing for others today (OR-11) except a free, ephemeral sandbox with sample data only, open to anyone who wants to try obop from the public beta (OR-20); a local demo with `obop dev up` stays optional. See section 9.
- **Dates:** the first delivery (W1) runs 2027-01-01 to 2027-09-30, cut to the smallest honest loop; public beta (0.5) 2027-07-06, with no commercial offer, and the free hosted sandbox is meant to open with it, if its go/no-go of 2027-06-01 passes (D83, OR-20); German pack stable 2027-09-30; 1.0, the first stable release, 2028-04-18. The W1 window, the German date and the 1.0 target are RATIFIED (OR-13); the 0.x dates are Proposed (D26; section 12 and [DECISIONS 5.2](DECISIONS.md#52-waves-and-calendar-proposed-d26)).
- **State and risks:** early code exists and the repository is public (since 2026-10-26); the product described here is mostly not built yet. The top risks are whether a very small team (the owner plus AI agents, self-funded, OR-6) can deliver on the kept dates, the accounting and localisation burden, and the German compliance dependencies (section 14.1).
-- **Owner decisions:** the rulings of 2026-10-01, 2026-10-27, 2026-10-28 and 2026-10-29 (OR-1 to OR-35) are RATIFIED and 16 questions (Q1, Q3, Q4, Q7, Q10, Q13, Q15, Q17, Q19, Q23, Q27, Q29, Q30, Q31, Q33, Q42) are closed, and five more were closed on 2026-10-28: Q57 and Q58 by answers of the platform team (not owner rulings) and Q59, Q60 and Q61 by the owner (OR-35 d, e, f); that is 21 of the 61 questions in all; the others remain open, reduced or deferred, and the first answers (Q20 on 2026-10-23, then Q8 and Q18) fall due soon (section 14.2).
+- **Owner decisions:** the rulings of 2026-10-01, 2026-10-27, 2026-10-28 and 2026-10-29 (OR-1 to OR-37) are RATIFIED and 16 questions (Q1, Q3, Q4, Q7, Q10, Q13, Q15, Q17, Q19, Q23, Q27, Q29, Q30, Q31, Q33, Q42) are closed, and five more were closed on 2026-10-28: Q57 and Q58 by answers of the platform team (not owner rulings) and Q59, Q60 and Q61 by the owner (OR-35 d, e, f); that is 21 of the 61 questions in all; the others remain open, reduced or deferred, and the first answers (Q20 on 2026-10-23, then Q8 and Q18) fall due soon (section 14.2).
**How to read.**
@@ -377,7 +377,7 @@ Classes are those of the Odoo 20 catalogue [R01 s3, s4]. Dispositions: *Ships*,
| Helpdesk (EE) | Ships: `helpdesk` | W3 |
| Studio (EE); Properties (CE) | Ships, open: `customize`; custom fields with three-tier storage | W1-W3 |
| Developer platform, external API, apps store | Ships: free generated API, CLI and MCP; the free plugin registry (search, install, copy; only OSI-approved-licence plugins are listed, OR-17); no marketplace commerce (OR-3) | W1; W2; n/a |
-| Odoo mobile apps (EE) | *Not planned*: a suite of native apps; the PWA stays the default client. *Ships* (Proposed, D87): one React Native (Expo) app, Mileage, since React Native (Expo) apps are allowed as obop clients (OR-34) and a PWA is not known to be able to record a trip while the phone is locked (general knowledge, not tested; [spec/mobile.md](spec/mobile.md)). The limits to first-party apps and to cases a PWA cannot serve, and keeping Tauri mobile out, are Proposed ([ux s8](spec/ux.md#8-out-of-scope-non-goals-and-later)) | Beside the waves (ROADMAP 3.4) |
+| Odoo mobile apps (EE) | *Not planned*: a suite of native apps; the PWA stays the default client. *Ships* (Proposed, D87): one React Native (Expo) app, Mileage, which works on both Android and iOS (OR-36), since React Native (Expo) apps are allowed as obop clients (OR-34) and a PWA is not known to be able to record a trip while the phone is locked (general knowledge, not tested; [spec/mobile.md](spec/mobile.md)). The limits to first-party apps and to cases a PWA cannot serve, and keeping Tauri mobile out, are Proposed ([ux s8](spec/ux.md#8-out-of-scope-non-goals-and-later)) | Beside the waves (ROADMAP 3.4) |
| AI: agents, skills, MCP, AI fields (EE) | Ships: MCP W1, `ai` W3; voice, image and web-page generation and AI live-chat agents not planned through W3 (revisited in W4) | W1, W3 |
| Industries, 60 plus bundles (EE) | Deferred: industry bundles as data packs | W4+ |
| Fiscal localisations, 229 `l10n_*` modules | Ships: DE W1, FR W2, PL W3; AT, CH W3 (CH unresearched); BE, NL, ES, IT, Nordics W4 via third-party maintainers; rest community | W1-W4 |
@@ -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 D90, 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-37, 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 4816483d..616648f3 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -6,7 +6,7 @@ Scope: Delivery plan from M0 (2026-10-01) to 2030-04 for a free, Apache-2.0 proj
Related: [DECISIONS.md](DECISIONS.md) (binding), [CONVENTIONS.md](CONVENTIONS.md) (naming), [spec/cli.md](spec/cli.md) (the agentic-first command surface), [ENGINEERING.md](ENGINEERING.md), [BUSINESS.md](BUSINESS.md), [LOCALIZATION.md](LOCALIZATION.md), [ARCHITECTURE.md](ARCHITECTURE.md), [PRODUCT.md](PRODUCT.md).
-**How to read this document.** The plan was written on 2026-10-01 before any code; early code now exists (CLAUDE.md has the code map) but the product described here is mostly not built and no design partner has agreed to use it yet. The owner rulings of 2026-10-01, 2026-10-27, 2026-10-28 and 2026-10-29 (OR-1 to OR-35) are RATIFIED and reflected here: the whole project is Apache-2.0 and free, there is no paid software, the team is the owner plus AI agents, SpaceCorps hosts nothing for others today apart from a free hosted sandbox with sample data only that opens with the public beta (OR-11, OR-20, D83) and, planned but not offered, a paid hosting service on dedicated Sliplane servers (OR-22), and the waves keep their dates while the scope of W1 shrinks (OR-13, OR-15, D86). The repository `SpaceCorps/obop` has been public since 2026-10-26, which overtook gate G0. What the owner has not answered stays Proposed. Every date carries one of these markers.
+**How to read this document.** The plan was written on 2026-10-01 before any code; early code now exists (CLAUDE.md has the code map) but the product described here is mostly not built and no design partner has agreed to use it yet. The owner rulings of 2026-10-01, 2026-10-27, 2026-10-28 and 2026-10-29 (OR-1 to OR-37) are RATIFIED and reflected here: the whole project is Apache-2.0 and free, there is no paid software, the team is the owner plus AI agents, SpaceCorps hosts nothing for others today apart from a free hosted sandbox with sample data only that opens with the public beta (OR-11, OR-20, D83) and, planned but not offered, a paid hosting service on dedicated Sliplane servers (OR-22), and the waves keep their dates while the scope of W1 shrinks (OR-13, OR-15, D86). The repository `SpaceCorps/obop` has been public since 2026-10-26, which overtook gate G0. What the owner has not answered stays Proposed. Every date carries one of these markers.
| Marker | Meaning |
|---|---|
@@ -184,9 +184,9 @@ K11 is listed in time order; K1 to K10 keep their earlier numbers.
"Dated" lists anchors and D26 dates; "Work" is (proposal) and is an ordered backlog for the owner and the agents, not a staffing plan. Answer dates follow section 8 of DECISIONS.md.
-**Beside the quarters (D87, OR-34; no calendar date).** The first native slice, "one background-recorded trip from a real phone" ([spec/mobile.md](spec/mobile.md) 9), is client work beside the waves: Android first, development builds, synthetic data only. It is outside the W1 cut (D86) and is proposed as an exit criterion of no wave; it draws on the same agent capacity as the waves (4.4), so the owner decides whether anything slips; the main session relayed on 2026-10-27 that the owner asked for it to start (relayed, not quoted; OR-34), and it carries no date of its own (the calendar's labels are milestones, OR-32). Work, in order: the Expo and Metro spike on the pnpm workspace; `@obop/client` generated from `/api/openapi.json`; the mobile client class of `obop-oauth`, the `device` model and revocation (done 2026-10-28, [spec/mobile.md](spec/mobile.md) 8.5); the `mileage` package with ingestion and validation; the app (sign-in, Start, foreground service, SQLite outbox, upload, Stop, Submit; review and confirmation are in the back office); label tables and docs. The real-phone acceptance test is the owner's to run or to witness. Auto-stop, maps, push, reimbursement and iOS are not in it. Step 4 is built (2026-10-28): the Mileage backend as the built-in module `crates/modules/mileage` (trips and points, the review, the distance computed at `end`), with its rules inside the module and not as generic kernel features ([spec/mobile.md](spec/mobile.md) 5.8 and 8.6).
+**Beside the quarters (D87, OR-34, OR-36; no calendar date).** The first native slice, "one background-recorded trip from a real phone" ([spec/mobile.md](spec/mobile.md) 9), is client work beside the waves: development builds, synthetic data only, and the app works on both Android and iOS (OR-36; iOS is not a later phase). It is outside the W1 cut (D86) and is proposed as an exit criterion of no wave; it draws on the same agent capacity as the waves (4.4), so the owner decides whether anything slips; the main session relayed on 2026-10-27 that the owner asked for it to start (relayed, not quoted; OR-34), and it carries no date of its own (the calendar's labels are milestones, OR-32). Work, in order: the Expo and Metro spike on the pnpm workspace; `@obop/client` generated from `/api/openapi.json`; the mobile client class of `obop-oauth`, the `device` model and revocation (done 2026-10-28, [spec/mobile.md](spec/mobile.md) 8.5); the `mileage` package with ingestion and validation; the app (sign-in, Start, foreground service, SQLite outbox, upload, Stop, Submit; review and confirmation are in the back office); label tables and docs. The real-phone acceptance test is the owner's to run or to witness. Auto-stop, maps, push and reimbursement are not in it. iOS is not a later phase (OR-36): where it enters the order of work above is for [spec/mobile.md](spec/mobile.md) 9 to say, and no date is promised. Step 4 is built (2026-10-28): the Mileage backend as the built-in module `crates/modules/mileage` (trips and points, the review, the distance computed at `end`), with its rules inside the module and not as generic kernel features ([spec/mobile.md](spec/mobile.md) 5.8 and 8.6).
-**Beside the quarters (D88, D89; Proposed; no calendar date).** The platform team that builds on obop asked for model rules and delete rules (asks 11.2, 11.5 and 11.6 of its amendment of 2026-10-28), and the decision records D88 and D89 are the designs; nothing in them is built. This is the lead's proposed order of that work on 2026-10-28, not a schedule and not a promise, and it moves if the owner ranks the work otherwise: ask 11.4 (the keyed lookup of the unique check, in review as pull request 28 on 2026-10-28); ask 11.3 (length rules on text fields; the obop session's queued package for it, which is not a record of this repository); the tracking package of D75 (tracking by `Dispatcher::register_crud` and the write context in `Dispatcher::run`, which D75 lists as not built); D88 slice 1 (model rules without the clock, which carries ask 11.5 and the part of ask 11.2 that needs no clock); D89 slice 0a (a neutral refusal for today's `refuse`, one scan per human delete) and then the rest of D89 in the order of its record (0b, 1, 2, then 5 after the D75 tracking package and the D77 guard check that `Unit` calls (D77 Decision 1); 3 with D76 and D78; the bench of slice 4 before the delete time is described as measured); ask 11.1 (a list filtered by what the caller has not seen, which needs its own decision record, and none exists yet); and ask 6.3 (erasure and retention, which D89 serves only with a declared policy per reference). The place of D88 slice 2 (the clock and `frozen`) is not fixed. Each commit that widens what the manifest parser accepts (four in all) takes the next patch number of the 0.2 line (D88 Decision 14), so the 0.x labels of the calendar stay milestone names (OR-32). The owner has ruled on ten points (OR-35 a to h, j and k: on 2026-10-28 five of D89 and one of D88; on 2026-10-29 the approver view of D89, and three questions on patch releases that concern both records, the amendment of MODULES.md 3.1 (h), API fields and field descriptions (j) and the generated texts that tell agents what each operation does (k: the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions); part i confirms a gloss); no question of D88 or D89 is open.
+**Beside the quarters (D88, D89; Proposed; no calendar date).** The platform team that builds on obop asked for model rules and delete rules (asks 11.2, 11.5 and 11.6 of its amendment of 2026-10-28), and the decision records D88 and D89 are the designs; nothing in them is built. This is the lead's proposed order of that work on 2026-10-28, not a schedule and not a promise, and it moves if the owner ranks the work otherwise: ask 11.4 (the keyed lookup of the unique check, in review as pull request 28 on 2026-10-28); ask 11.3 (length rules on text fields; built and merged as D90 on 2026-10-29, squash 32a0758 of PR 33, tag `v0.2.1-r1`); the tracking package of D75 (tracking by `Dispatcher::register_crud` and the write context in `Dispatcher::run`, which D75 lists as not built); D88 slice 1 (model rules without the clock, which carries ask 11.5 and the part of ask 11.2 that needs no clock); D89 slice 0a (a neutral refusal for today's `refuse`, one scan per human delete) and then the rest of D89 in the order of its record (0b, 1, 2, then 5 after the D75 tracking package and the D77 guard check that `Unit` calls (D77 Decision 1); 3 with D76 and D78; the bench of slice 4 before the delete time is described as measured); ask 11.1 (a list filtered by what the caller has not seen, which needs its own decision record, and none exists yet); and ask 6.3 (erasure and retention, which D89 serves only with a declared policy per reference). The place of D88 slice 2 (the clock and `frozen`) is not fixed. Each commit that widens what the manifest parser accepts (four in all for D88 and D89; the field limits of D90 were a fifth and took 0.2.1 first, with the owner's acceptance of a patch per commit and the tag form `v0.2.N-rM`, OR-37 b) takes the next patch number of the 0.2 line (D88 Decision 14), so the 0.x labels of the calendar stay milestone names (OR-32). The owner has ruled on ten points (OR-35 a to h, j and k: on 2026-10-28 five of D89 and one of D88; on 2026-10-29 the approver view of D89, and three questions on patch releases that concern both records, the amendment of MODULES.md 3.1 (h), API fields and field descriptions (j) and the generated texts that tell agents what each operation does (k: the create, update and bulk_create tool descriptions, every delete description and the security_event descriptions); part i confirms a gloss); no question of D88 or D89 is open.
**2026-Q4 (M0 to M2), W0.**
- Dated: 10-05 vite-plus 1.0.0 eligible (D23). 10-15 handles reserved; 10-15 to 10-22 Rust 1.99.x window (1.99.0 adopted). 10-23 Q20. 10-28 Node 26 enters LTS. 11-12 Rust 1.100.0 expected. 11-13 Q8 and Q18. 11-16 CEN code lists take effect. 11-27 spike readout, Q6. 12-01 0.1 preview, migration ledger starts. 12-09 Product Liability Directive: software counts as a product for anything placed on the market after this date (counsel). 12-15 counsel opinion on the name (Q2), Q16 and Q26. 12-31 Q9 and Q22, W0 exit, throughput review; SMTP AUTH basic off by default.
diff --git a/docs/spec/finance.md b/docs/spec/finance.md
index a1cb1351..3a8aebdb 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-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.
+Decisions and rulings cited by ID (D1-D90, OR-1 to OR-37, 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 bd16bdf0..8f6e1713 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-D90, OR-1 to OR-35, Q1-Q61 | [DECISIONS.md](../DECISIONS.md) |
+| D1-D90, OR-1 to OR-37, 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 8e4dec74..cc35c5c7 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-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.
+Decision IDs (D1-D90, OR-1 to OR-37, 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 5590e87e..618fc1bc 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-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).
+Decision IDs (D1-D90), the owner rulings OR-1 to OR-37, 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 d030c747..0826b0ce 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 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.
+Decision IDs (D1 to D90), owner rulings (OR-1 to OR-37) 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 |
|---|---|