Skip to content

Field limits: max_length, min_length, min and max (kinds-limits, D90) - #33

Merged
rorychatt merged 23 commits into
mainfrom
feat/kinds-limits
Oct 29, 2026
Merged

rorychatt merged 23 commits into
mainfrom
feat/kinds-limits

Conversation

@rorychatt

@rorychatt rorychatt commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What

Field limits (ask 11.3 of the platform team, DAT-060 / MTG-N4), recorded as decision D90 (PROPOSED):

  • max_length and min_length on text and longtext, counted in Unicode code points, not bytes.
  • min and max (inclusive, exact) on number, money and decimal (a decimal bound is a string or a whole number with at most the field's scale; a TOML float is refused).
  • A default maximum length for a text field that declares none, and a hard cap for a declared one.
  • Refused with 400 limit_violated (a new error code, origin D90) on create and update (REST, MCP and the CLI) and on the effects of actions. The message names the model, the field and the limit. To a person who typed the value it also says what was written, for example lim_note.headline must be at most 2000 characters; this has 2001; to an agent, and from an action effect, it says only what the field takes (... must be at most 2000 characters), because the count would reveal the length of a personal value behind a mask token or of a field the caller cannot read. The code is kept when an action effect meets a limit. The other writers refuse in their own way, and not with that code: a bulk row (and so obop import and the Import dialog) is named without it (row N: ...); a bulk_create call in the default all_or_nothing mode answers a plain 400 that names the first five rows, while partial mode and dry_run answer a success that lists every refused row; a record that a connector pushes in is rejected as that one item (the others of the delivery are kept); a public submit of a [model.public] form is rejected as a whole with the text of a direct write and no code, and the two pages that call it (the contact form and the shop checkout) show the visitor only "Something went wrong. Please try again.".
  • Shown in /api/meta, the OpenAPI document (input schemas only), the MCP tool schemas, obop modules check and the back office form (a code-point counter that a screen reader is told about once per band, bounds, the server's refusal on the field).

FOR THE PRODUCT OWNER BEFORE THE MERGE: the proposed default and cap

These four numbers are proposals, not rulings. They are named constants in one place, crates/kernel/obop-registry/src/limits.rs; nothing else repeats them.

default (field declares nothing) cap (largest max_length a field may declare)
text 2,048 10,000
longtext 100,000 1,000,000

Why (the first wording of this paragraph claimed more of its sources than they say; this one is corrected): the numbers are round numbers with a margin over what was measured. The longest text value in the seed data is 140 characters and the longest longtext value 2,040, so 2,048 and 100,000 leave about fifteen and about fifty times. 2,048 is also the conservative ceiling for a URL, the longest ordinary one-line value: sitemaps.org asks for fewer than 2,048 characters and old Internet Explorer stopped near it (modern browsers accept far more, so it 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). 100,000 is about fifty printed pages. GitHub's 65,536 for an issue body and about 102,400 bytes for a clipped HTML mail are unofficial third-party figures that were not checked against current documentation; they are analogies, not sources. The caps are about five and ten times the defaults. The HTTP request body limit (2 MiB) still applies first.

Cheapest to change before the first release that ships the keys. Lowering a number later does more than make some writes fail: check_installed_module and Manifest::field_def run the install gate for every installed package at every start, so an installed package that declares a max_length above a lowered cap (or a min_length above a lowered default with no max_length) is refused at start, lands in the failed list and registers none of its models (the hazard D89 Decision 15 describes for a rollback; reproduced in review). Raising a number breaks nothing. D90 Reversibility and K43 say so; the field gate is not relaxed for an installed package (only the checks of a set_default migration step are, see the second round below).

The default is a behaviour change for every existing package and built-in module: a write of more than 2,048 characters into a text field, or more than 100,000 into a longtext field, that used to succeed is refused from this release on. That includes an action effect that copies a longer value into a shorter field (the install gate checks the kind of an effect's values, not their limits, so it is refused when the action runs), and a record that a connector pushes in: that item is rejected, the others of the delivery are kept (202 with counts; 422 when all were rejected), and a float set_default migration step into a number or money field (value = 5.0) that 0.2.0 accepted is refused by obop modules check, an upload, a preview and an upgrade to such a package (an installed package is exempt, D90 Decision 7). Stored values are never re-checked (below), so no existing record is locked. These are the visible differences that OR-35 h does not name (that part covers "the text or the status of a refusal", not a refusal where there was none); D90 Decision 9 lists them as visible differences and as a point for the owner, and does not claim the amendment covers them.

Longest stored value (measured, seed data only). A fresh instance with the demo data, the demo shop and blog and all 23 bundled packages installed (94 readable models, 477 text values): longest longtext value 2,040 characters (connector_provider.config_fields, 100,000 allowed); longest text value 140 characters (connector_provider.legal_name, 2,048 allowed). That says nothing about a real instance; there is no tool for that yet (K46), so the owner has to measure a real database before the merge.

Not registered as questions. D90 raises two owner points (the four numbers; whether a patch release may add a refusal where there was none). Neither is an entry of the question register in DECISIONS.md section 8 and the question counts do not include them; K43 tracks both. (Reviewed: the first wording said D90 "adds no open question".)

The contract (D90 in docs/DECISIONS.md has the full record)

  • Counting. str::chars. å, ä, ö and an emoji count 1 each; 2,000 Swedish letters and 2,000 emoji pass a limit of 2,000, 2,001 are refused. A combining accent or a family emoji counts as many code points as it has. Nothing is trimmed or normalised. The browser counts the same way with a surrogate-pair aware loop (codePoints in web/apps/backoffice/src/limits.ts).
  • required and min_length. required is unchanged (present and non-null; "" still passes it). min_length = 1 refuses "" but accepts " ", because a length is what was sent and nothing is trimmed. So for the platform's "a post of spaces is stored today", min_length = 1 gives them the empty-string half and not the whitespace half; a blank-text rule is not delivered (K45). min_length does not make a field required either: a missing or null value is not checked.
  • A value stored before a limit. On update, a value equal to the stored one is not checked against the limits again (a decimal is equal by value: a stored 100.50 and a resent 100.5 are the same), so a record holding 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 on a field nobody touched. The check before a governed state move compares with the stored record the same way. A different value is checked even if shorter than before. Every other check runs as always. The back office does the same. Existing rows are never scanned.
  • Install gate (D41). Refused, naming the field and the key: max_length 0, min_length 0, max_length above the cap, min_length above max_length (or above the default when there is no max_length; the refusal names the cap and where more room is), max_length/min_length on any kind but text and longtext, min/max on any kind but number, money and decimal, a bound of the wrong shape (for a decimal: a float, more fractional digits than the scale, beyond the precision), min above max, a limit on a computed or numbered field, an unparseable bound. A set_default of a number or money field is a TOML integer; a float, and a value outside the limits of its field, is refused when a package is installed, uploaded, previewed or upgraded to, and not when an installed package is read again (it still loads at start, upgrades and rolls back as the previous core read it). All 23 bundled packages and the built-in modules install unchanged (obop modules check packages/*.toml: 23 ok; none declares a limit).
  • Surfaces. /api/meta: max_length on every written text field (default included), min_length, min, max. OpenAPI: maxLength, minLength, minimum, maximum on input schemas only (decimals as x-obop-minimum / x-obop-maximum), none on record schemas. MCP: the same keywords, none for personal-data fields (an agent sees a mask token). A sentence is added to the field description only for a limit the author declared. The upgrade plan shows the limits a field is held to, the default included, and which way they move (limits tighten from max_length 5000 to max_length 2048 (default); values already stored are not re-checked). The generated create, update and bulk_create tool descriptions (OR-35 k) and the OpenAPI operation summaries are not changed.
  • Back office. Counter ("8 of 10 characters") from four fifths of the limit, red above it, with a visually hidden role="status" region that says "Close to the limit of 10 characters" / "Over the limit of 10 characters" once when the band changes; the control is aria-invalid while a changed value is over; client-side refusal before sending; number inputs get min/max; the server's own sentence is shown on the field when the browser did not know the limit. Deliberate deviation: the native HTML maxlength attribute is not set (it counts UTF-16 units and silently cuts a paste), so the control can hold more than the limit and the form says so. Storybook: none exists for the back office.

First fix round: the review of 5034695 (24 confirmed findings, 6 refuted), head f755757

Five code commits and four document commits on top of 5034695; nothing rebased, nothing force-pushed. Where the lead decided and the owner did not, D90 says "lead decision".

Finding Fix
F0, F8: an effect lost the code errors::wrap keeps limit_violated first through the effect wrapper and the nested set_state wrapper; create, adjust and nested set_state are classified limit_violated with their hint. Bulk rows stay without the code, and the wording says so
F1: no test of the governed pre-flight two tests; both mutations of guards.rs are caught
F2: the plan said none the plan shows the effective limits and the direction
F7: the count told an agent the length of a masked value, and an effect could state a value copied from an unreadable field the count is withheld from agents and from every effect (Actor.agent, check_effect_fields); a person and a public visitor keep it
F9: inbound connector items test only (the ingress is not changed); K44 and D90 corrected
F10: decimal unchanged value and public number/decimal tests; both mutations are caught
F14: counter accessibility status region once per band, aria-invalid while a changed value is over
F19: a float set_default into number/money refused
F20: min_length advice names the cap and where more room is
F3, F11, F23, F12, F15, F16, F17, F18, F4, F5, F6, F13, F21, F22 documents (DECISIONS.md D90, MODULES.md, DEPLOY.md, ISSUES.md K43 to K45, CLAUDE.md code map)

Second fix round: the closure check of f755757 (34 of 36 closed, 2 partial, 14 new items, none high), head 174fc07

Three code or test commits and one document commit on top of f755757 (13810a5, 2c4bb9e, c86e7e7 and 174fc07); nothing rebased, nothing force-pushed. Each claim was checked in the code before it was acted on.

Item What changed
1. A float set_default into a number or money field stopped an already installed package from loading (manifest.rs: parse_installed reached validate_migrations and default_matches; three findings said it) Real regression of the first round: reverting the flag (mutation N4 below, which is the pre-fix behaviour) fails both new tests. default_matches and validate_migrations take installed: a float is refused, and a set_default value is held to the limits of its field, on install, upload, preview and upgrade, and not when the stored text of an installed package is read (start, the stored side of an upgrade, a rollback). One thing beyond what was asked: the same flag also skips the limits check of the set_default value for an installed manifest, not only the float, because the previous core had no limits and the same start-up hazard applies; D90 Decision 7 says so in one sentence and the field gate (limits::problems) is not relaxed
2a, 2b. Test gaps a decimal breach for an agent and from an action effect states no this is (a_decimal_breach_states_no_value_to_an_agent_or_from_an_effect); the agent variant of the state-move pre-flight test, with an exact string
2c. The unchanged prop at RecordPanel.tsx:574 and InlineEdit.tsx:50 was untested one Playwright case: a stored 8-character value under a limit lowered to 5 is not aria-invalid in the panel or in a list cell, one more character makes it invalid, going back clears it
3. The public form and the connector push are not refused "with HTTP 400, code limit_violated" Checked: the public submit drops the code (public.rs:951, errors::plain) and both callers show err.generic (the contact form, obop-site content.rs:755-762, and the shop checkout, checkout.rs:1261-1263); a connector item is rejected per item (ingress.rs:159). MODULES.md (:91, :304), the D90 register row, Decision 5 and K45 now say it in their own clauses, and a bulk call answers a 400 only in all_or_nothing mode
4. MODULES.md types table a decimal bound is a string or a whole number with at most scale fractional digits
5. "It does not echo the value" now: it never repeats the text itself, states the length or the number written only to a person who typed it, never to an agent or from an effect (Decision 5, Threat model)
6. F13 was only partly closed reproduced in a real run with a platform.admin agent token: MCP only, for the kernel's virtual commands (module__install lists 45 inputs and 40 carry a maxLength; user_account__add 12 and 5), and the virtual handlers run no check. OpenAPI lists limits only for stored models (/api/module/install has a generic body). Of the two connection fields it lists, only last_error (2,048) is enforced (a 2,049-character one is refused on create with 400 limit_violated); connection.result (100,000, on /api/connection/create and /update) is another output-only field whose stated limit nothing enforces, because the connectors guard drops it on create and refuses it on update (third round, below). K44 (b), Decision 8 and this body are reworded; last_error is no longer an example of an unenforced limit and "not reproduced" is gone (the reviewers saw it; the dev-agent of the first round lacked platform.admin)
7. Lead decisions labelled in place Decision 1 (a whole number as a decimal bound), Decision 4 (the ingress as it is), K44 (d); the Consequences sentence now lists them all, including the two new ones (installed manifests not re-refused; the virtual-command schemas left as they are)
8. This body four document commits in the first round, not three; all seven strings, not two, are unread (below)

Third fix round: the second closure check of 174fc07 (all code and tests closed; four low document items), head d85f832

One document commit (d85f832) on top of 174fc07; no code changed. Each item was checked in the code and, where it is about behaviour, in a real run (built binary, scratch SQLite database, --dev-auth).

Item What changed
B. connection.result is not enforced Checked: a last_error of 2,049 characters answers 400 limit_violated on POST /api/connection/create (2,048 is accepted); a result of 100,001 characters is accepted on create and not stored, because normalise in crates/modules/connectors/src/guards.rs removes it on create (:384) and refuses it on update (:377). K44 (b) and Decision 8 cite last_error as the enforced example and name result (100,000 in openapi.json) as an output-only field whose stated limit nothing enforces; row 6 above says the same
C. Decision 8 had no lead-decision marker "The schemas were left as they are (a lead decision of the review of PR 33)" is in Decision 8; the Consequences sentence lists ten lead decisions and all ten are marked in place, this one included; this body's list says the same
D. A float set_default was a visible difference nobody listed Decision 9 now qualifies "additive" and lists it as (e) (five visible differences, not four, in the release notes); visible difference 6 and the paragraph about the default say it here; Decisions 4 and 7 already said the refusal is for install, upload, preview and upgrade-to and not for an installed package
E. Connections are not unchecked Checked: a connection is created and updated through the generic create and update (its limits apply). Only the connectors runtime's partial updates go straight to the store (last_sync_at and last_error, the latter cut to 200 characters, runtime.rs:394-402; state, runtime.rs:428 and handlers.rs:248; oauth_tokens, runtime.rs:1167; credentials at a revocation, runtime.rs:1296), and the consents and the sync runs. K44 (a) and Decision 4 say exactly that. A search of D90, MODULES.md, ISSUES.md K43 to K46, CLAUDE.md and this body found no other sentence that calls connections unchecked or connection.result enforced

Gates for this round (documents only): cargo fmt --check clean; the openapi drift test the_committed_openapi_document_is_current passes and vp run generate:types leaves no diff; vp check and vp build pass for web/apps/site (it assembles MODULES.md), deploy/build-site.sh assembles the site and Playwright site.spec.ts (28 tests, among them the manifest reference rendered from MODULES.md) passes. The full cargo, vp and Playwright runs below are those of 174fc07; the code is the same on the new head.

Visible differences (for the release notes)

  1. A write over the default length is refused (the owner point above), including an action effect that copies a longer value into a shorter field.
  2. A record that a connector pushes in with a text over a limit is rejected as an item; the rest of the delivery is kept; the sender's retry of the same event id is a replay (as for any rejected item).
    2a. A public form that breaks a limit is rejected as a whole, and the contact form and the shop checkout show the visitor only "Something went wrong. Please try again.", not the field or the limit (K45). Only a limit the form's own checks do not already catch gets there (the contact form checks the name at 120 and the message at 5,000 characters itself).
  3. /api/meta gains max_length on every written text field; OpenAPI input schemas of stored models and MCP tool schemas gain the keywords (the MCP schemas of the kernel's virtual commands state a limit on inputs that nothing enforces: module__install 40 of 45, user_account__add 5 of 12; OpenAPI does not, K44 (b)); a declared limit adds a sentence to the field description (OR-35 j).
  4. New error code limit_violated (400) and new upgrade-diff change kind field_limits (never breaking). These are new values of existing fields; no part of OR-35 names them, which is the record's reading (the wording of D89 Decision 15), and the owner may rule otherwise.
  5. The hint of the new code says a text field with no max_length has the default of its kind.
  6. A set_default migration step that puts a float into a number or money field (value = 5.0) was accepted on 0.2.0 and is refused by obop modules check, upload, preview and an upgrade to a package that carries it; a package that is already installed is exempt and loads, upgrades and rolls back as before (D90 Decision 9 (e), Decision 7).

Version rule (D88 Decision 14, OR-35 h/j/k)

  • CORE_VERSION is now 0.2.1 (the workspace version). This is the first commit that widens what the manifest parser accepts, because D88 and D89 are not built. A package that declares a limit writes requires_core = "^0.2.1"; the 23 bundled packages keep ^0.2. Test core_version_requirement_kinds_limits (number written in the test; 0.2.1 and CORE_VERSION satisfy it, 0.2.0 does not).
  • Tag after the merge would be v0.2.1-r1 by the form of D88 Decision 14; tags are made by hand and the lead decides.
  • If D88 or D89 lands first this takes the next number: LIMITS_CORE_PATCH in crates/kernel/obop-pkg/src/limits_tests.rs, the ^0.2.1 examples and the pins of the bump commit change with it.
  • Not reconciled, as before: D88 Decision 14 assigns the conforming edit of OR-32, D55 and the Alignment rules sentence (the literal 0.2.0) to the first of its four commits that lands. This is not one of the four, so that edit is not made (D90 Decision 9 says so). Two leftovers of the bump found in review are fixed: MODULES.md line 21 no longer names a number, and the second example in DEPLOY.md "Which build is running" reads 0.2.1. On 0.2.0 a package with max_length fails at parse time whatever requires_core says (same hazard as D88/D89).

Tests

New in total: 67 Rust tests (registry 15, commands 27 including the public submit, pkg 11, mcp 3, binary over the real router 8, CLI through the real binary 2, connectors module 1; 3 of them are new in the second round), 24 back office component tests (vp test), 6 Playwright tests (the first also asserts the status text and aria-invalid; the sixth is new in the second round). The three stores are covered: the commands tests run on memory, SQLite and PostgreSQL (OBOP_TEST_POSTGRES_URL).

Run on head 174fc07 (cargo, vp, Playwright; real output in the hand-back):

  • cargo fmt --check: clean. cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace --no-fail-fast: 69 suites, 2,148 passed, 0 failed, 6 ignored, both without and with OBOP_TEST_POSTGRES_URL (a scratch database obop_limits_fix3 on the shared container, dropped afterwards; the database named postgres was not used). That includes the_committed_openapi_document_is_current.
  • Web: the generated client types are current (regenerated, no diff); vp check passes for the 7 packages and for the e2e package (with its typecheck); vp test: ui 167, client 128 (10 integration tests skipped without a server), trip-core 26, backoffice 304, all passed; vp build passes for backoffice (also dist-admin), platform, site, client and trip-core; vp run i18n:check in web/packages/ui: catalogues ok.
  • Playwright, full suite over the real built app: 226 passed, 16 skipped, 0 failed. The process exits 1 on this machine only because the global teardown cannot delete its temp directory (EPERM on Windows); not touched.
  • Real run of the first round (built binary, scratch SQLite database, --dev-auth, a test package rr), see D90 Decision 5:
    • dev-agent listed a party and held the mask token NAME_4 for a 37-character name; writing the token into a field of max_length = 10 over REST and over MCP answers rr_note.headline must be at most 10 characters, 400 limit_violated with its hint, no count (the review had this has 39). The same text from an agent that typed 11 characters. A person (dev-sales) writing 11 characters is told ...; this has 11; a number outside its max likewise: must be at most 100; this is 101 to the person and must be at most 100 to the agent.
    • An action whose effect copies $self.salary (73,250, a field dev-sales cannot read, group hr.manager) into a number field with max = 100: dev-sales and dev-admin both get 400 limit_violated with its hint on the wire (REST and MCP), rr_src.finish: effect 1 (create) failed, nothing was changed: rr_target.cap must be at most 100, without 73250; the state did not move and no target row exists (the review had the value in the sentence and no code).
    • bulk_create with row 2 over the limit: a plain 400 1 of 2 row(s) are invalid, so nothing was created. row 2: ... (with the count for a person, without for the agent), no limit_violated code. An update that changes a value to a too-long one: ... this has 16 to a person.

Mutation checks (each guard broken in turn; the tests that should notice were run; all restored)

First round, 19, all caught: count bytes instead of code points; max exclusive; min exclusive; no default length; gate does not refuse above the cap; decimal min exclusive; decimal max exclusive; gate not run by check_basics; update re-checks an unchanged value; update never checks limits; a change effect not checked; a create effect not checked; bulk_create rows not checked; public submit not checked; limit_violated not a 400; MCP keywords for personal data; a migration set_default not held to limits; the browser counts UTF-16 units; the browser re-checks an unchanged text.

First fix round, 21 plus the end to end ones, all caught (the test that failed is in the commit messages and the hand-back): M1 the wrapper keeps no code; M2 the effect wrapper site is plain; M3 the nested set_state wrapper is plain; M4 an agent is told the count; M5 an effect states the value; M6 the pre-flight is given its own values as the stored ones; M7 the pre-flight has no stored record; M8 an unchanged decimal is not skipped; M9 the public decimal branch removed; M10 the public number branch removed; M11 the cap advice removed; M12 the refusal without a value says the value; M13 the plan ignores the default; M14 the plan never says which way; M15 a float default accepted; M16 the plan compares the spelling; M17 connector items unchecked; M18 aria-invalid even when unchanged; M19 no status role; M20 never invalid while over; M21 the count announced at every key, and two end to end: E1 aria-invalid never set while over fails limits.spec.ts:102, E2 no announcement near the limit fails limits.spec.ts:92.

Second round, 9 more, all caught: N1 the float arm for an installed manifest removed (fails an_installed_manifest_is_read_as_the_previous_core_read_it and a_float_set_default_is_refused_on_install_and_upgrade_but_not_in_an_installed_package); N2 limits_hold also run for an installed manifest (fails the first); N3 a float always allowed, so an install refuses nothing (fails both); N4 the installed flag not passed to validate_migrations (fails both); N5 a decimal breach always states the value, kinds.rs:170 (fails a_decimal_breach_states_no_value_to_an_agent_or_from_an_effect); N6 the pre-flight and every direct write state the value to an agent, crud.rs:185 (fails a_state_move_with_an_over_limit_field_is_refused_before_the_action_runs); N7 an effect states the value, crud.rs:206 (fails the decimal test); and, in the Playwright spec, N8 unchanged={false} in RecordPanel.tsx:574 (fails at the panel assertion, limits.spec.ts:252) and N9 the same in InlineEdit.tsx:50 (fails at the cell assertion, limits.spec.ts:264).

Not done / not verified

  • Not delivered, in docs/ISSUES.md: K43 (the numbers are proposals, and what lowering them does), K44 (writers that do not go through the checks, such as Rust handlers that write straight to the store, were not read one by one; effect maps are checked against limits only when the action runs; the MCP input schemas of the kernel's virtual commands state a limit that nothing enforces, and so does connection.result in the OpenAPI document and /api/meta states one on the output-only text fields of the virtual models; the connectors runtime's partial updates of a connection, its consents and its sync runs go straight to the store; a caller learns the limit of a field they cannot read), K45 (no date bounds, no pattern, no blank-text rule, no limits in the Customize module builder; server-rendered forms do not carry maxlength; a refused public submit shows a generic error), K46 (no scan or report of stored values above a limit).
  • Decided by the lead and not the owner, and written as such in D90: the corrected reasons for the numbers; keeping limit_violated through an effect but no other table code; withholding the count from agents and effects (a public visitor and a person keep it); no install-time check of effect maps; the schemas of the virtual commands left as they are (Decision 8 now says so in place); the ingress left as it is; the disclosure of a hidden field's limit accepted; a whole-number decimal bound accepted; installed manifests not re-refused for a set_default step (second round); the registry's integer-on-decimal arm kept (it cannot be reached from a package, but an in-code caller of FieldDef::with_limits reaches it and a test covers it).
  • Mobile device tokens are principals of kind user, so a phone writing a personal field directly is told the count like any person; only principals of kind agent are not.
  • The individual commits were not built one by one; the head was tested.
  • The longest-value survey is seed data, not a real instance.
  • The machine-drafted translations have not been read by a speaker: all seven new back office strings (the five of the first round and the two announcements) in German, Spanish, Finnish, French, Polish and Swedish.
  • GitHub Actions on head d85f832 (first attempt, nothing rerun): rust pass (7m56s), web pass (42s), e2e pass (6m23s), lockfiles pass, all pass (run 40934424213). On 174fc07, the head of the previous round: rust pass (8m16s), web pass (46s), e2e pass (5m02s), lockfiles pass, all pass.

🤖 Generated with Claude Code

rorychatt and others added 23 commits October 29, 2026 15:49
…d the install gate (D90)

A FieldDef may carry limits: max_length and min_length (Unicode code points,
not bytes) on text and longtext, min and max (inclusive, exact) on number,
money and decimal. A text field with no max_length has the default of its
kind (2,048 and 100,000) and a declared one may not exceed the cap (10,000
and 1,000,000); the four numbers are named constants in limits.rs and are
proposals for the product owner. The D41 gate refuses limits that cannot
hold (0, above the cap, min above max, wrong kind, a bound that does not fit
the decimal's precision and scale).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…_violated (D90)

create, update, each row of bulk_create (and so imports and the CLI), the
effects of actions, the pre-flight of a governed update and the public
submit all hold a written value to the limits of its field. The refusal is a
400 with the new code limit_violated and names the model, the field, the
limit and what was written. A value equal to the stored one is not
re-checked on update, so lowering a limit locks no existing record.
/api/meta (FieldInfo) carries max_length, min_length, min and max.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…ts to them (D90)

FieldM takes max_length, min_length, min and max (decimal bounds are strings,
a TOML float is refused) and reads them into the field, so obop modules
check, the install gate and the upgrade diff see them. A changed limit is
the listed, never breaking, change kind field_limits, and a set_default
value of a migration step must satisfy the limits of its field.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
maxLength, minLength, minimum and maximum on the input schemas of the write
commands (decimals as x-obop-minimum and x-obop-maximum, because a decimal
is a string), none on record schemas and none on personal-data fields in
MCP, where an agent sees a mask token. A sentence is added to the field
description only for a limit the author declared.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… follow (D90)

The field-limit keys widen what the manifest parser accepts, so by the
version rule of D88 Decision 14 this is the first patch number of the 0.2
line. A package that declares a limit writes requires_core = "^0.2.1"; the
23 bundled packages keep ^0.2. Changes every pin of the literal 0.2.0 that
D88 Decision 14 lists: the core-version tests, the committed openapi.json
(regenerated, with the maxLength keywords), the website snapshots, the
e2e assertions and the examples in DEPLOY.md and spec/cli.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…(D90)

REST create, update, bulk_create and CSV import over a real router; the
default length on every built-in model; /api/meta, the OpenAPI document and
the MCP tool schemas; installing a package whose limit cannot hold; and
obop modules check through the real binary on good and bad packages.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…the server's refusal (D90)

A code-point counter ("8 of 10 characters") from four fifths of a limit,
red above it; a text or number outside a limit is refused before it is
sent, naming the field and the limit; number inputs take min and max; a
value the person did not change is not held to a limit, as on the server;
the server's own limit_violated sentence is shown on the field when the
browser did not know the limit. The native maxlength attribute is not set:
it counts UTF-16 units and silently cuts a paste. The six machine-drafted
catalogues carry the new strings.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Five Playwright tests over the real built app: the counter near a limit and
the refusal before sending, 2,001 characters and 2,000 emoji against a limit
of 2,000, a number and an exact decimal outside their range, the server's
refusal when the browser did not know the limit, and an import that names
the row and the limit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…NS.md, list K43 to K46

D90 is the decision record for ask 11.3 of the platform team: the keys, how a
length counts, the proposed default length and cap for the product owner, where
limits are checked, the refusal, the rule for a value stored above a lowered
limit, the gate, the surfaces, the version rule (0.2.1) and what is not
delivered. MODULES.md gets the keys, the gate messages and the kinds table;
CONVENTIONS.md the new spellings; ISSUES.md K43 to K46; the decision range
becomes D1 to D90 where it is stated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…d reword the default rationale (D90 review F6, F7, F20)

Breach::requirement and message_without_value say what a field takes and nothing about the
value written. The min_length advice names the cap and reports a min_length above it directly.
The comments on the two defaults now give the real rationale (round numbers with a margin over
measured seed data) and mark the URL, GitHub and mail figures as unofficial analogies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…ent or from an effect (D90 review F0, F1, F7, F8, F10)

An effect's wrapper and the nested set_state wrapper keep the code limit_violated first
(errors::wrap), so the wrapped answer classifies as that code with its hint on every surface.
A refusal of a limit no longer says how long or how large the value was when the caller is an
agent (its mask tokens are restored before the handler) or when an action effect wrote it
(it may be a copy of a field the caller cannot read). Tests: effects (create, adjust, nested
set_state), agent token, $self.salary, the governed pre-flight both ways, a decimal stored
above a lowered bound in any spelling, and the public submit of a number and a decimal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…they move, a float set_default is refused (D90 review F2, F3, F19)

limits_text now shows the effective maximum of a text or longtext field (a dropped max_length is
the default of its kind) and the plan says whether the limits tighten, loosen or both, comparing
what is held and not how it is spelled. A float set_default into a number or money field is
refused: a write refuses it too, and it skipped the bounds. The doc comment of min and max now
says a decimal bound is a string or a TOML integer with at most scale fractional digits.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…f (D90 review F9)

No change of behaviour: the delivery is answered with counts when an item was accepted, refused
when all were rejected, the run records partial or failed, and the event id stays taken.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…lid while over (D90 review F14)

A status region (always present for a field with a limit, empty far from it) says that the
text is close to the limit and that it is over, in words that do not change within a band, so
nothing is announced per key. The control carries aria-invalid while the text is over and
about to be sent; a value left as stored is savable (Decision 6) and is not marked. The six
catalogues carry the two new strings (machine-drafted).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…, F5, F6, F7, F8, F9, F10, F12, F14, F15, F17, F18, F19, F20, F21, F22, F23)

Record in DECISIONS.md what the fix round built and what it learned:
- Reversibility: lowering a number also makes check_installed_module and
  Manifest::field_def refuse an installed package at every start (F4).
- Decision 1 and 7: a decimal bound is a string or a TOML integer with at
  most the scale; the registry's integer-on-decimal arm stays (F3, F23).
- Decision 3: the reasons for the four numbers, without overstated
  sources (F6). Decision 4: inbound connector records are checked, effect
  maps only when the action runs (F5, F9). Decision 5: the code survives
  an effect, the count is withheld from agents and effects, bulk rows are
  named without the code (F0, F7, F8). Decision 6 and 8: decimals by value,
  the plan shows the effective limits, the counter is announced (F2, F10,
  F14). Decision 9: OR-35 wording of D89, a fourth visible difference (F17).
- Rollback points to D89 Decision 15 (F18); the register row and the
  paragraph on counts say what is and is not registered (F21).
- Lead decisions of the review are marked as such.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…F3, F7, F11, F16, F23)

A decimal bound is a string or a whole number with at most the scale
(not "exactly"), a bulk row is named without the code, an agent and an
effect get no count, the limits of an effect's target are checked when
the action runs, MODULES.md line 21 no longer names a version, and the
second example of "Which build is running" reads 0.2.1.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…code map (D90 review F4, F5, F9, F13, F21, F22)

K43 gets the start-up hazard of a lowered cap, K44 the inbound connector
records that are checked, the schemas that list output-only fields as
input, the effect maps and the hidden field, K45 the module builder.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…output-only fields (D90 review F13)

The MCP tool connection__create offers result (maxLength 100000) and
last_error (2048) as inputs, as does openapi.json; the module.install and
user_account.add figures are the reviewers' and were not reproduced here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…(D90 closure check, F19)

The float refusal and the limits check of a set_default value now run for an
upload and an upgrade only. parse_installed threads its flag into
validate_migrations and default_matches, so a package installed on the
previous core with a float default in a number or money field still loads,
upgrades and rolls back instead of landing in the failed list.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…nt or from an effect (D90 closure check)

A bounded decimal target, a decimal the HR group alone reads and a grant action that copies it
join the rig; the agent variant of the governed state move sends a masked token as the title.
Both mutations of the withholding (kinds.rs and crud.rs for the decimal, guards.rs for the
pre-flight) now fail a named test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…until it is changed

The unchanged prop at RecordPanel.tsx:574 and views/InlineEdit.tsx:50 had no test:
setting either to false left every spec green. One Playwright case now opens a
stored 8-character value under a limit lowered to 5 (the counter says 8 of 5), checks
the control is not aria-invalid in the panel and in a list cell, that one more
character makes it invalid, and that going back clears it. Both mutations fail it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…and what the schemas of the virtual commands state (closure check of PR 33)

The public submit and the connector push are not refused with a 400 and limit_violated:
the public submit drops the code and the contact form and shop checkout show a generic
error, a connector item is rejected by itself. MODULES.md, the D90 register row, Decision 5
and K45 now say so, and a bulk call answers a 400 only in all_or_nothing mode.
A decimal bound is a string or a whole number (MODULES.md types table). The text no longer
says the refusal 'does not echo the value'. K44 (b) and Decision 8 now state what a real run
showed: only the MCP schemas of the virtual commands (module__install 40 of 45 inputs,
user_account__add 5 of 12) carry a limit nothing enforces; OpenAPI lists limits only for
stored models. Decision 1, Decision 4 (ingress) and K44 (d) are labelled lead decisions, and
Decision 7 and the Consequences say that installed manifests are not re-refused.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… closure check of PR 33)

K44 (b) and Decision 8: only connection.last_error is an output-only limit that is enforced
(a 2,049-character last_error is refused on create); connection.result is dropped on create
and refused on update by the connectors guard, so its 100,000 in openapi.json is enforced by
nothing. Decision 8 carries its lead-decision marker. K44 (a) and Decision 4: a connection is
created and updated through the generic create and update, so its limits apply; only the
runtime's partial updates, the consents and the sync runs go straight to the store.
Decision 9: a float set_default into a number or money field, which 0.2.0 accepted, is now a
fifth visible difference (installed packages are exempt).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@rorychatt
rorychatt merged commit 32a0758 into main Oct 29, 2026
5 checks passed
@rorychatt
rorychatt deleted the feat/kinds-limits branch October 29, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant