diff --git a/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-PLAN.md b/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-PLAN.md new file mode 100644 index 00000000..cea16f01 --- /dev/null +++ b/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-PLAN.md @@ -0,0 +1,215 @@ +--- +phase: quick-261104-fx1 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate.go + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate_test.go + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/settings_string_url_test.go + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/backend_users_admin.go + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/README.md + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/docs/database/casts-and-validation.md + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/lang/pl/validation.yaml + - /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/lang/pl/validate.yaml +autonomous: true +requirements: + - quick-261104-fx1 + +estimate: + tokens: 70000 + raw_tokens: 70000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "lagoon.Validate checks min:N and max:N as a length on every field whose rules contain neither numeric nor integer (Laravel semantics): nullable|max:5 and max:5 refuse a 10-character string with exactly [\"The title may not be greater than 5 characters.\"] (this reverses the 261104-f4x pin), and required|max:255|unique:posts now limits the title as the README example implies" + - "The length is Laravel's getSize without a numeric rule: characters (utf8 runes) of a string, of a pointer to one or of a named string type; characters of a number's decimal form (int, uint, float); the element count of a slice or map (not a []byte), with the min.array / max.array messages (\"The tags may not have more than 2 items.\"); booleans, dates, structs, fixed-size arrays and byte slices are not measured" + - "Fields with numeric or integer keep their numeric range behaviour and messages; between keeps its existing check; an empty or blank value (nil, nil pointer, pointer to \"\", \"\", whitespace-only string, empty slice or map) skips min/max, and required stays the rule that rejects emptiness" + - "A min/max bound that is not a non-negative whole number is the unrecognized-rule error on every non-numeric field, whatever the value (max:abc on nil errors)" + - "Through cabana's settings save sequence a rule without string (nullable|max:5) refuses a 6-character value with a message on the field; the Administrators screen still refuses a 3-character password with a message containing \"at least 4 characters\"" + - "With the pl translator the request validator's string failure reads \":attribute musi być ciągiem znaków.\" and lagoon.Validate's numeric min/max failures read \":attribute musi być nie mniejszy od :min.\" / \":attribute nie może być większy niż :max.\" (no longer \"znaków\")" + - "modules/lagoon/README.md and docs/database/casts-and-validation.md describe the new min/max behaviour accurately; the docs checker passes" + - "The SUMMARY lists every non-numeric min/max rule without string/url in the sibling plugins and app plugins (lagoon.Validate sources only), with the DB column size and a SAFE/RISK verdict, plus a release-note line for v0.6.1" + artifacts: + - path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate.go + provides: "lengthRange on every non-numeric field, ruleLength helper (runes / decimal form / element count), min.array and max.array message routing and fallbacks" + - path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate_test.go + provides: "reversed pin in TestValidateStringLength, new TestValidateLengthWithoutStringRule, translated length and catalog cases" + - path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/settings_string_url_test.go + provides: "code field with nullable|max:5 proving the change through the settings save sequence" + - path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/lang/pl/validation.yaml + provides: "Polish string line and a header note on the deliberate divergence from Winter's pl file" + - path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/lang/pl/validate.yaml + provides: "numeric wording for lagoon::validate.min and lagoon::validate.max" + key_links: + - from: "modules/cabana/crud.go:882, relation.go:1220, relation_child.go:126, settings.go:267" + to: "lagoon.Validate -> validateField length block" + via: "valuesForRules(model, rules) model field values (string, *string, ints, slices) and FormRules virtual values (password)" + - from: "validateField length block" + to: "ruleLength + lengthBound" + via: "minLen/maxLen parsed right after the token loop for every non-numeric rule string" + - from: "validateMessage" + to: "lagoon::validation.min.string / max.string / min.array / max.array" + via: "phrasebook keys with English fallbacks (cabana passes a nil translator)" +--- + + +Make `lagoon.Validate` (the model validator behind every cabana admin save and settings save) apply Laravel's `min`/`max` semantics on all non-numeric fields: compare a string's length in characters and a list's element count, not only when `string` or `url` is present. Translate the remaining English line of the pl request catalog that these rules touch (`string:`), fix the pl numeric `min`/`max` lines of the model catalog that wrongly say "znaków", and audit the sibling plugins for rules that will start enforcing a limit. + +Purpose: `nullable|max:255`, `required|max:255|unique:posts` (the README example) and `required|min:4|confirmed` (the Administrators password) currently accept any length, although the docs and the request validator (`validate_rules.go` `size()`) say min/max compare length. Quick 261104-f4x fixed this only for `string`/`url` fields and pinned the gap with a test; this task closes it (source todo `.planning/todos/pending/2026-11-04-lagoon-validate-min-max-length-on-text-fields.md`). Risk: a stored value longer than its declared limit fails its next admin save, hence the read-only audit. Framework target v0.6.1 (in progress): no tag, no push, no sibling repo edits. + +Output: two framework commits (Task 1 behaviour + tests + docs; Task 2 pl catalogs + test), then a read-only audit and the full gates whose findings go into the SUMMARY. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md +@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate.go +@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261104-f4x-lagoon-validate-string-and-url-rules/261104-f4x-SUMMARY.md + +Working repo: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go (framework, branch master). Do not edit any sibling repo (`../sm-*`, `../fonoteka.go`), STATE.md or state.json. + +Facts gathered during planning (do not re-derive): +- validate.go `validateField` (HEAD 7e6c426): token switch lines 57-121 (sets hasNumeric, hasInteger, hasString, hasURL, minArg, maxArg, betweenMin/Max; `confirmed`, `boolean`, `integer`, `mimes`, `different` return early inside the loop). Lines 122-135: `lengthRange := (hasString || hasURL) && !hasNumeric && !hasInteger`, then `lengthBound("min", minArg)` / `lengthBound("max", maxArg)` (absent = -1, malformed or negative = `lagoon: unrecognized validation rule "max:abc"`). Line 136: nullable short-circuit. Lines 139-161: numericRange; the non-numeric `else` branch (151-161) appends `min=`/`max=` tags for between AND for minArg/maxArg, but the tags loop (line 211) skips every tag named required, min or max, so the minArg/maxArg tags are dead; the between tags only keep `len(tags) > 0` true so the between length check (224-237, counts BYTES with `len(s)`, or `len(fmt.Sprint(val))` for non-strings) runs. Lines 176-178: required. Lines 179-205: the string/url block (blank skip via ruleString + TrimSpace, string-type failure returns alone, url message, then min.string/max.string only when `lengthRange && isStr`; returns early when it has messages, before the tags loop and unique). Helpers: lengthBound 378-389, ruleString 391-409, validateMessage 505-562 (key routing at 511-514 sends "between.numeric", "min.string", "max.string" to `lagoon::validation.`; English fallbacks below). +- The request validator (`validate_rules.go` 364-387, `size()`): a number under a numeric rule is the number; `arrayLen` (442-458: slices and maps) is the element count; an UploadedFile is KB; otherwise `utf8.RuneCountInString` of the PHP string form. This plan mirrors the string / number-string / slice-map parts only (no files in lagoon.Validate). +- `between` on non-numeric fields is already length-checked (bytes). Per the task brief it stays exactly as it is; report the byte-vs-character difference as a follow-up, do not change it. +- Pin to reverse: validate_test.go TestValidateStringLength lines 418-460; doc comment 418-420 says rule strings without string or url keep their old results; rows 438-441 pin `max:5` and `nullable|max:5` on "0123456789" as passing. Test helpers: `runRuleCases(t, field, []ruleCase{{name, rule, value, want}})` (validate_test.go 320-340, exact message slices, nil tx/model/translator); `requestTranslator(t)` and `inLocale("pl")` and `mustValidate(t, ctx, input, []RequestRule)` in validate_request_test.go 22-55. +- Message catalogs: en validation.yaml has `min.array` "The :attribute must have at least :min items." and `max.array` "The :attribute may not have more than :max items."; pl validation.yaml has `min.array` ":attribute musi mieć przynajmniej :min elementów." and `max.array` ":attribute nie może mieć więcej niż :max elementów.". pl validation.yaml line 64 `string: "The :attribute must be a string."` is English because Winter's own pl/validation.php line 80 is English (the file header says text is copied verbatim from Winter). Other English lines in pl validation.yaml (dimensions, distinct, file, in_array, json, present, required_unless) belong to rules this task does not touch: leave them, list them in the SUMMARY. pl validate.yaml lines 10-11 `max: ":attribute nie może być dłuższy niż :max znaków."` / `min: ":attribute musi mieć przynajmniej :min znaków."` are only used by `numericRangeMessage` (numeric/integer bounds), so a Polish numeric failure reads "znaków" (characters); the correct Winter numeric lines are in pl validation.yaml lines 37 and 43. No test in this repo or the siblings pins the pl validate.yaml min/max text. +- Framework callers of lagoon.Validate: modules/cabana/crud.go:882 (form saves, FormRules virtual values), relation.go:1220 (pivot), relation_child.go:126, settings.go:267; all pass a nil translator, and crud.go turns a returned error into a `CapabilityError` (so a malformed bound breaks the save). Framework rule strings affected: cabana/contracts.go:49-50 BackendUserRole name/code `required|max:255`; contracts.go:169-170 BackendUser login/email `required|max:255`; backend_users_admin.go:476-482 usersAdmin FormRules password `nullable|min:4|confirmed` / `required|min:4|confirmed`, login/email `required|max:255`; docs/examples/blog/models/post.go:34-35; lagoon example_test.go:163 ExampleValidate `required|max:10` on "" (output unchanged: required) and 294. account.go uses lagoon.ParseRules (request validator), not affected. +- backend_users_admin.go:868-874 (storePassword doc comment) says the length is checked in the hook "because the form's min:4 rule is a string rule, which the model validator does not apply" — false after this change. storePassword runs in the form hooks after lagoon.Validate (lines 753, 814), so lagoon.Validate's English message now wins for a too-short password; TestAdministratorsPasswordAndIdentity (administrators_postgres_test.go:496, Docker) expects the substring "at least 4 characters", which the new message contains. +- cabana settings_string_url_test.go: model stringURLSettings (Title *string, Website *string, Language string), stringURLSettingsFields YAML const, compileStringURLSetting, TestSettingsStringURLRules (DB-free table, reflect.DeepEqual on map[string][]string) and TestSettingsStringURLRulesPostgres (AutoMigrate, Put). +- Several sessions work in this repo: run `git status --short` before editing and before staging; stage only the listed files by path; leave other uncommitted edits (STATE.md, state.json, G15OFFICE.md, WINDOWS.md, phase 33 files) alone. No co-author trailer in commit messages (user rule). Docker is normally reachable directly; if the socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md). + + + + + + Task 1: min and max as length on every non-numeric field in lagoon.Validate, proven through a cabana settings save + /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate.go, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate_test.go, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/settings_string_url_test.go, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/cabana/backend_users_admin.go, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/README.md, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/docs/database/casts-and-validation.md + modules/lagoon/validate.go (already in context), modules/lagoon/validate_test.go lines 1-35 and 230-460, modules/lagoon/validate_rules.go lines 364-387 and 442-458, modules/lagoon/validate_request_test.go lines 20-56, modules/cabana/settings_string_url_test.go (whole file), modules/cabana/backend_users_admin.go lines 864-892, modules/lagoon/README.md line 32, docs/database/casts-and-validation.md line 111 + + - Reversed pin (TestValidateStringLength): max:5 and nullable|max:5 on "0123456789" give exactly ["The title may not be greater than 5 characters."]; the string/url rows and the two between rows keep their expectations. + - New TestValidateLengthWithoutStringRule, field title: required|max:255 passes 255 "ż" and fails 256 "ż" with ["The title may not be greater than 255 characters."]; nullable|max:5 passes nil, "", " ", (*string)(nil), pointer to "", "abcde" and fails "abcdef" and a pointer to "abcdef"; min:4 fails "abc" with ["The title must be at least 4 characters."] and passes "", nil, " "; required|min:4 on "" gives only ["The title field is required."]; required|email|max:255 passes "a@example.com". + - Numbers without numeric/integer (field title): max:4 fails int 12345 with ["The title may not be greater than 4 characters."]; passes int 2024 and float64(2024); max:2 fails float64(1.5) ("1.5" is 3 characters). + - Not measured (field title): max:0 on true, max:1 on a time.Date value, max:1 on NewDate(2026, 11, 4) all pass. + - Lists (field tags): max:2 fails []string{"a","b","c"} with ["The tags may not have more than 2 items."]; passes []any{"a","b"}; min:2 fails []string{"a"} with ["The tags must have at least 2 items."]; min:2 passes an empty []string{} (blank skip); max:1 fails map[string]any{"a":1,"b":2} with ["The tags may not have more than 1 items."]; max:3 passes []byte("abcdef") (byte slices are not measured). + - Numeric unchanged (field views): integer|max:10 on 11 gives ["The views may not be greater than 10."]; numeric|min:0|max:10 on "5" passes; all existing TestValidateNumericRange* and TestValidateBetweenYear cases pass unchanged. + - Malformed bounds without string: max:abc on "x" and on nil return a non-nil error containing "max:abc"; nullable|min:-1 on "abc" returns an error containing "min:-1". + - Translated (requestTranslator + inLocale("pl")): max:5 on "abcdef" gives "title nie może być dłuższy niż 5 znaków."; max:2 on tags []string{"a","b","c"} gives "tags nie może mieć więcej niż 2 elementów.". + - cabana TestSettingsStringURLRules: with a new code field (rule nullable|max:5, no string), body {code: "abcde"} and {code: ""} give no errors, {code: "abcdef"} gives {"code": ["The code may not be greater than 5 characters."]}; every existing case keeps its result; TestSettingsStringURLRulesPostgres still passes with the extra column. + - ExampleValidate output is unchanged. + + +Run `git status --short modules/ docs/` first. If modules/cabana/backend_users_admin.go or modules/cabana/settings_string_url_test.go carry another session's uncommitted edits, leave that file alone and report it in the SUMMARY instead of editing it. + +Tests first (RED): in modules/lagoon/validate_test.go update the TestValidateStringLength doc comment (drop the claim that rule strings without string or url keep their old results; say min and max compare the length on every non-numeric field) and change the two pinned rows to expect the max message; add TestValidateLengthWithoutStringRule as runRuleCases tables (fields title, tags, views) covering every behavior row above, plus the malformed-bound loop (Validate with nil tx/model/translator, assert err contains the token) and a translated table in the style of TestValidateStringURLTranslated (or extend that test) for the two pl rows. In modules/cabana/settings_string_url_test.go add `Code *string` with gorm column code to stringURLSettings, "code" to Fillable, rule "code": "nullable|max:5" to Rules(), a `code` text field to stringURLSettingsFields, and the three code cases to TestSettingsStringURLRules; update the type's doc comment to say one rule has no string token. Run the lagoon and cabana tests and confirm the new rows fail for the expected reason (no message where one is wanted) before implementing. + +Implementation in modules/lagoon/validate.go (GREEN): +- Lines 122-135: set lengthRange to "neither numeric nor integer" (drop the hasString/hasURL condition) and keep the two lengthBound calls, so a malformed or negative bound fails loudly on every non-numeric rule string whatever the value. Rewrite the comment above: on a field that is not numeric or integer, min and max are a length (Laravel's getSize); a malformed bound fails loudly so a typo in Rules() cannot disable a limit unnoticed. +- Non-numeric else branch (lines 151-161): delete the two appends of the minArg and maxArg tags (dead, the tags loop skips min and max); keep the two between appends, which keep the between length check running. Do not change the between check itself. +- Replace the `if hasString || hasURL` block (lines 179-205) with a block that always runs after the required check: compute s, isStr from ruleString and blank as isEmptyValue(val) or (isStr and strings.TrimSpace(s) is empty). A blank value skips the whole block (Laravel skips non-implicit rules for a blank value; required has already handled emptiness). Otherwise, in this order: hasString and not isStr returns only the "string" message (unchanged); hasURL with a non-string or a value urlPattern does not match appends the "url" message (unchanged); then, when lengthRange and minLen or maxLen is set, call the new helper ruleLength(val) and when it measures the value append "min."+kind when the length is below minLen and "max."+kind when above maxLen, with params min and max from minArg and maxArg. Return the collected messages when there are any (early return, as today); otherwise fall through to the tags loop and unique check untouched. +- New unexported helper ruleLength(val any) (n int, kind string, ok bool), next to ruleString, with a doc comment naming it Laravel's getSize without a numeric rule: dereference pointers and interfaces (a nil one is not measured); Kind String gives utf8.RuneCountInString and kind "string"; signed and unsigned integer kinds give the rune count of strconv.FormatInt / strconv.FormatUint base 10, and float kinds of strconv.FormatFloat with format 'f' and precision -1 (the form numericString already uses), kind "string"; Slice (except one whose element Kind is Uint8, i.e. []byte, json.RawMessage) and Map give rv.Len() and kind "array"; everything else (bool, structs including time.Time, Date, TimeOfDay, fixed-size arrays such as UUIDs, byte slices) returns ok false. Grep modules/lagoon for an existing ruleLength before naming; pick another name if it collides. +- validateMessage: add "min.array" and "max.array" to the case that routes keys to lagoon::validation., and add English fallbacks "min.array" giving "The " + attr + " must have at least " + params["min"] + " items." and "max.array" giving "The " + attr + " may not have more than " + params["max"] + " items." (the en validation.yaml lines). + +cabana comment: in modules/cabana/backend_users_admin.go rewrite the last sentence of the storePassword doc comment (lines 869-873): the form's min:4 rule already refuses a shorter password in lagoon.Validate before the hook runs; the hook repeats the check so it never hashes one, and adds bcrypt's 72-byte limit. Comment only, no code change. + +Docs, same commit (CLAUDE.md documentation rule): in modules/lagoon/README.md line 32 replace the sentence starting "On a `string` or `url` field `min` and `max` compare" with: on a field without `integer` or `numeric`, `min` and `max` compare the length in characters of a string or of a number's decimal form (`max.string`, `min.string` messages) and the element count of a slice or map (`max.array`, `min.array`); an empty or blank value, a boolean and a date are not measured, and a bound that is not a whole number is an error. In docs/database/casts-and-validation.md line 111 replace "On a `string` or `url` field `min` and `max` compare the length in characters (`The title may not be greater than 255 characters.`), and a bound that is not a whole number is an error." with plain prose saying the same as the README (any field that is not `integer` or `numeric`; characters of a string or of a number, with the 255-characters example message; elements of a list, with the example `The tags may not have more than 5 items.`; empty or blank values, booleans and dates are not measured, use `required` to refuse an empty value; a bound that is not a whole number is an error). Keep the existing sentence "`between` compares the length on any other field." and the rest of the paragraph. + +Run `git status --short`, stage exactly the six files of this task by path (fewer if a cabana file was skipped), and commit: feat(lagoon): check min and max as length on every non-numeric field (quick-261104-fx1). No co-author trailer. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/lagoon ./modules/cabana && go test -short -count=1 ./modules/lagoon && go test -short -count=1 ./modules/cabana -run 'TestSettingsStringURLRules|TestSettingsSecret' && go test -count=1 ./modules/cabana -run 'TestSettingsStringURLRulesPostgres|TestAdministratorsPasswordAndIdentity' && go test -count=1 ./cmd/summer -run TestDocsTree + + min/max are enforced as a length on every non-numeric field (characters of strings and numbers, items of slices and maps) with Laravel's string and array messages; numeric/integer ranges and between behave as before; blank values skip; malformed bounds error on any non-numeric field; the pin is reversed; the cabana settings sequence refuses an over-long code; the Administrators password test still passes; README, docs and the storePassword comment are accurate; one commit with the task's files exists. + + + + Task 2: Polish string line in the request catalog and numeric min/max lines in the model catalog + /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/lang/pl/validation.yaml, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/phrasebook/lang/pl/validate.yaml, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/validate_test.go + modules/phrasebook/lang/pl/validation.yaml (whole file, 69 lines), modules/phrasebook/lang/pl/validate.yaml (15 lines), modules/lagoon/validate_test.go TestValidateNumericRangeMessageTranslated (lines 264-273), modules/lagoon/validate_request_test.go lines 150-160 (a pl mustValidate assertion) + + - Request validator, pl: ValidateRequest with rule string on input {"s": 123} under inLocale("pl") gives {"s": ["s musi być ciągiem znaków."]}; the same under inLocale("en") still gives "The s must be a string.". + - lagoon.Validate, pl: integer|max:10 on 11 gives "views nie może być większy niż 10."; integer|min:0 on -1 gives "views musi być nie mniejszy od 0."; the existing pl between assertion ("year musi zawierać się w granicach 1889 - 2100.") and the pl max.string/min.string rows from Task 1 and 261104-f4x are unchanged. + + +Tests first: add TestValidatePolishStringAndNumericLines to modules/lagoon/validate_test.go covering the behavior rows (requestTranslator, inLocale, mustValidate with []RequestRule{{Field: "s", Rules: ParseRules("string")}}, and Validate with the translator for the views rows). Run it red. + +Catalogs: in modules/phrasebook/lang/pl/validation.yaml set `string:` to ":attribute musi być ciągiem znaków." (the wording pl validate.yaml already uses), keeping the file's quoting; extend the header comment (lines 1-3) with one sentence saying `string`, which Winter's pl file leaves in English, is translated here, so a pl request gets a Polish line where the PHP backend answers in English. Leave every other English line (dimensions, distinct, file, in_array, json, present, required_unless) untouched. In modules/phrasebook/lang/pl/validate.yaml set `max:` to ":attribute nie może być większy niż :max." and `min:` to ":attribute musi być nie mniejszy od :min." (Winter's max.numeric / min.numeric lines; these keys only serve numeric/integer bounds). Do not touch the en catalogs. + +Run `git status --short`, stage exactly the three files by path and commit: fix(phrasebook): translate the pl string message and the numeric min/max lines (quick-261104-fx1). No co-author trailer. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test -short -count=1 ./modules/lagoon -run 'TestValidate' && go test -short -count=1 ./modules/phrasebook && grep -c 'ciągiem znaków' modules/phrasebook/lang/pl/validation.yaml + + pl request responses use the Polish string message, pl numeric min/max failures from lagoon.Validate read as numbers, all other catalog lines are unchanged, the new test passes, one commit with the three files exists. + + + + Task 3: Read-only risk audit of sibling rules and the full gates + /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-SUMMARY.md + /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/lagoon/backend_admin_migrations.go (column definitions of backend_users and backend_user_roles only, grep login/email/name/code), /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/docs/examples/blog (its migration for acme_blog_posts title/slug) + +Audit (read-only; never edit, stage or commit in a sibling repo). Roots, relative to /media/nvme/dev/golem15/summercms.io/summercms: every `sm-*-plugin` directory, plus the app-owned plugin directories that have no `sm--plugin` sibling: fonoteka.go/plugins/golem15/fonoteka, sm-bm-app/plugins/jz/bm, sm-summercmsio-app/plugins/golem15/summercms, sm-demo-app/plugins/demo, sm-grzybyfunkcjonalne-app/plugins/grzyby. Confirm that list with `ls -d fonoteka.go/plugins/*/* sm-*-app/plugins/*/*`, dropping directories whose basename has an `sm--plugin` sibling (those are submodule copies of the canonical repo) and add any app-owned plugin directory the list misses. + +Only lagoon.Validate sources count: methods `Rules() map[string]string` (used by cabana crud, relation and settings saves), `FormRules(ctx, op)` on admin controllers, and rule maps passed to a direct `lagoon.Validate(` call (grep for it in the roots and in the whole of fonoteka.go and sm-*-app, excluding plugin submodule copies). Rules built with lagoon.ParseRules or lagoon.RequestRule are the request validator (already Laravel semantics) and are out of scope. Skip _test.go files. + +For every rule entry whose tokens include min: or max: but none of numeric, integer, string, url, record: repo, file:line, field, rule string, the Go field type on the model (string, *string, int, slice, ...), the column definition (grep the column name in the plugin's updates/ or migrations/ directory for varchar(N), text, size:N, or the gorm tag on the model; settings models may live in their own table), and a verdict: SAFE when the column is varchar(N) with N less than or equal to the rule's max (stored data cannot exceed the rule) and there is no min; RISK-MAX when the column is text, unbounded varchar or wider than the rule's max (existing rows may exceed and would fail their next admin save); RISK-MIN when there is a min (existing short values fail their next save); NUMERIC-SUSPECT when the Go field is a number (the rule now compares digit count, probably meant numeric/integer); BROKEN when an argument is not a non-negative whole number or is built at runtime (now an unrecognized-rule error, which cabana turns into a CapabilityError on save). Count `between` entries separately as unchanged (already length-checked, in bytes) without per-row detail. Note any plugin-side code that duplicates a rule's length check (for example sm-oauthserver-plugin console/import_settings.go keeps its own length map) as a later simplification candidate. Do the same for the framework-internal rules listed in the context (cabana contracts.go, backend_users_admin.go FormRules, docs/examples/blog/models/post.go) against modules/lagoon/backend_admin_migrations.go and the example migration. + +Gates: go vet ./...; go test -short -count=1 ./...; go test -count=1 ./modules/lagoon/... ./modules/cabana/... with Docker (through sudo -u golem if the socket is denied; say which way ran); go test -count=1 ./cmd/summer -run TestDocsTree; cd examples/hello and go test ./... (separate workspace module). A failure that is pre-existing and unrelated is reported, not fixed; anything caused by this change is fixed within Tasks 1-2's files and committed as a follow-up fix commit with the same quick id. + +Write the SUMMARY (template from execute-plan) with: commits; the audit table grouped by repo with totals per verdict; the framework-internal findings; a draft v0.6.1 release-note line (Changed: lagoon.Validate checks min and max as a length on every field that is not integer or numeric, characters of a string and items of a list, as Laravel does; a stored value longer than its rule's max fails its next admin save until shortened; a min/max bound that is not a whole number is now an error); follow-ups for the user: (1) non-numeric between counts bytes, not characters (Laravel's mb_strlen), so Polish text hits the limit early; (2) cabana passes a nil translator to lagoon.Validate (crud.go:882, relation.go:1220, relation_child.go:126, settings.go:267), so every model-rule message in the admin is English, and a pl admin's too-short password message, previously translated by storePassword, is now English; (3) the remaining English lines of pl validation.yaml; (4) the pl string translation deliberately diverges from the PHP backend's English pl response; (5) the source todo can move to done. No STATE.md edit (the orchestrator handles state). + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short -count=1 ./... && go test -count=1 ./modules/lagoon/... ./modules/cabana/... && go test -count=1 ./cmd/summer -run TestDocsTree && (cd examples/hello && go test -count=1 ./...) && test -f .planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-SUMMARY.md + + All gates are green (or pre-existing failures are reported); the SUMMARY holds the per-repo audit table with column sizes and verdicts, the framework-internal findings, the release-note line and the follow-ups; no sibling repo was modified. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| admin client -> cabana form/settings/relation saves | Field values (titles, codes, passwords, tag lists) are client-controlled and validated by lagoon.Validate before they are stored | +| plugin author -> model Rules() / FormRules() | Rule strings are compiled code; a declared limit must be enforced, and a malformed one must fail loudly | +| stored rows -> next admin save | Rows written while limits were unenforced may already exceed them | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-261104fx1-01 | Tampering | validateField min/max on non-numeric fields | medium | mitigate | min/max are now enforced on every non-numeric field (runes for strings, element count for lists), so an admin client can no longer store values past a plugin's declared limit (for example a 3-character administrator password or a 10 000-character role name); tests pin the boundaries | +| T-261104fx1-02 | Denial of service | admin re-save of rows that already exceed a limit | medium | mitigate | Task 3 audits every lagoon.Validate rule in the sibling plugins against its column size and lists RISK rows; the release-note line tells operators that an over-long stored value must be shortened on its next save | +| T-261104fx1-03 | Tampering | malformed min/max bounds | low | mitigate | A bound that is not a non-negative whole number returns the unrecognized-rule error on every non-numeric field, so a typo cannot silently disable a limit; the audit flags BROKEN rules before the release | +| T-261104fx1-04 | Denial of service | rune count and element count | low | accept | utf8.RuneCountInString and reflect Len are linear or constant time; request bodies are bounded by the server's body limits | +| T-261104fx1-05 | Information disclosure | validation messages | low | accept | Messages name only the attribute and the bound, never the submitted value | +| T-261104fx1-SC | Tampering | go module installs | low | accept | No new dependency; standard library (unicode/utf8, strconv, reflect) only | + + + +- No Docker: go vet ./... clean; go test -short -count=1 ./... green; examples/hello tests green. +- Docker (testcontainers Postgres): go test ./modules/lagoon/... ./modules/cabana/... green, including TestSettingsStringURLRulesPostgres and TestAdministratorsPasswordAndIdentity (the SUMMARY says whether sudo -u golem was needed). +- Docs: go test -count=1 ./cmd/summer -run TestDocsTree passes. +- `git show --stat` on each task commit lists only that task's files; other sessions' uncommitted files are untouched; no sibling repo has changes from this task. + + + +- nullable|max:255, required|max:255 and required|min:4|confirmed enforce their limits in admin saves, with Laravel's character and item messages in en and pl. +- Numeric/integer ranges, between and blank-value handling behave as before. +- The pl request catalog's string line is Polish; the pl model catalog's numeric min/max lines no longer say "znaków". +- Two commits on master in summercms.go (behaviour; catalogs), no co-author trailer, no tag or push. +- The SUMMARY gives the user a complete, verdict-tagged list of sibling rules that start enforcing a limit, a release-note line and the follow-ups. + + + +Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-SUMMARY.md` when done + diff --git a/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-SUMMARY.md b/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-SUMMARY.md new file mode 100644 index 00000000..bd23066a --- /dev/null +++ b/.planning/quick/261104-fx1-lagoon-validate-min-max-length-on-text-f/261104-fx1-SUMMARY.md @@ -0,0 +1,211 @@ +--- +phase: quick-261104-fx1 +plan: 01 +subsystem: lagoon +tags: [validation, laravel-compat, phrasebook, i18n] +status: complete +requires: [quick-261104-f4x] +provides: + - lagoon.Validate min/max as length on every non-numeric field (characters of strings and numbers, items of slices and maps) + - min.array / max.array messages in lagoon.Validate + - Polish string line in the pl request catalog, numeric wording for pl lagoon::validate.min/max +affects: [cabana admin form, settings and relation saves, plugin APIs that call lagoon.Validate directly] +tech-stack: + added: [] + patterns: [Laravel getSize without a numeric rule (ruleLength)] +key-files: + created: [] + modified: + - modules/lagoon/validate.go + - modules/lagoon/validate_test.go + - modules/cabana/settings_string_url_test.go + - modules/cabana/backend_users_admin.go + - modules/lagoon/README.md + - docs/database/casts-and-validation.md + - modules/phrasebook/lang/pl/validation.yaml + - modules/phrasebook/lang/pl/validate.yaml +decisions: + - "lagoon.Validate applies min/max as a length on every field without numeric or integer (Laravel's getSize): runes of a string or of a number's decimal form, element count of a slice or map; blank values, booleans, structs (dates), fixed arrays and byte slices are not measured" + - "A malformed min/max bound is the unrecognized-rule error on every non-numeric rule string, whatever the value" + - "pl validation.yaml translates the string line although Winter's pl file leaves it English (deliberate divergence, noted in the file header)" +metrics: + duration: ~45min + completed: 2026-11-04 +actuals: + tokens: 7650 + tasks: 3 + commits: 2 +commits: 2 +plan_head_before: 0785336a502fce29c8c349a7b72ecfda9455661a +plan_head_after: 145251481b40cddce31bb47c34f3a41b8323c6f2 +--- + +# Quick 261104-fx1: lagoon.Validate min/max length on text fields Summary + +`lagoon.Validate` now applies Laravel's `min`/`max` semantics on every field that is not `integer` or `numeric`: the character count (runes) of a string or of a number's decimal form with the `min.string`/`max.string` messages, and the element count of a slice or map with the new `min.array`/`max.array` messages. Before this, the check ran only when the rule string held `string` or `url`. The pl catalogs now give a Polish `string` message for requests and numeric wording for the `lagoon::validate.min`/`max` lines. + +## Commits + +| Task | Commit | Message | +|------|--------|---------| +| 1 | 335f8e6 | feat(lagoon): check min and max as length on every non-numeric field (quick-261104-fx1) | +| 2 | 1452514 | fix(phrasebook): translate the pl string message and the numeric min/max lines (quick-261104-fx1) | +| 3 | (none) | Read-only audit plus gates, results below | + +## What changed + +- `validateField`: `lengthRange` is now `!hasNumeric && !hasInteger`. `lengthBound` parses both bounds before the nullable short-circuit, so `max:abc` fails on a nil value too. The dead `min=`/`max=` tags in the non-numeric branch are gone. The `between` tags stay, so the `between` length check (still in bytes) runs as before. +- The string/url block now always runs after `required`. A blank value (nil, nil pointer, `""`, whitespace, empty slice or map) skips it. Otherwise the order is: the `string` type failure (returned alone), the `url` format failure, then `min.`/`max.` from the new `ruleLength` helper. +- `ruleLength(val) (n, kind, ok)`: Laravel's getSize without a numeric rule. Strings, named string types and pointers to them give runes, kind `string`. Signed and unsigned integers and floats give the runes of their base-10 form (`FormatFloat 'f', -1`), kind `string`. Slices (except byte slices) and maps give `Len()`, kind `array`. Everything else is not measured. +- `validateMessage` sends `min.array`/`max.array` to `lagoon::validation.` and has English fallbacks for both. +- pl `validation.yaml` `string:` is now ":attribute musi być ciągiem znaków." and the header says why. pl `validate.yaml` `max:`/`min:` are now ":attribute nie może być większy niż :max." / ":attribute musi być nie mniejszy od :min.". +- The README, `docs/database/casts-and-validation.md` and the storePassword doc comment are updated. + +## Tests + +- TDD: the new rows (reversed pin, `TestValidateLengthWithoutStringRule`, `...Translated`, the cabana `code` cases, `TestValidatePolishStringAndNumericLines`) failed for the expected reason first ("[] want [...]"), then passed after the change. +- Task 1 verify: `go vet` on lagoon and cabana is clean. `go test -short ./modules/lagoon` and the cabana `TestSettingsStringURLRules|TestSettingsSecret` tests pass. The Docker tests `TestSettingsStringURLRulesPostgres` and `TestAdministratorsPasswordAndIdentity` (all subtests, including too_short) pass. `TestDocsTree` passes. +- Task 2 verify: `go test -short ./modules/lagoon -run TestValidate` and `./modules/phrasebook` pass. `grep -c 'ciągiem znaków'` gives 1. +- Full gates: see "Gates" below. + +## Audit: rules that start enforcing a limit + +Scope: lagoon.Validate sources only, meaning `Rules()`, `FormRules()` and maps passed directly to `lagoon.Validate(`. `_test.go` files are excluded. The roots are the 25 `sm-*-plugin` repos plus the app-owned plugins `fonoteka.go/plugins/golem15/fonoteka`, `sm-bm-app/plugins/jz/bm`, `sm-summercmsio-app/plugins/golem15/summercms`, `sm-demo-app/plugins/demo` and `sm-grzybyfunkcjonalne-app/plugins/grzyby`. The other `plugins/*/*` entries in the apps are submodule copies of `sm-*-plugin` repos. Rule entries holding min/max but none of numeric/integer/string/url: **57**. + +Verdict key: +- SAFE: varchar(N) with N <= max. Postgres varchar counts characters, the same unit as the rule. +- RISK-MAX: text, or a varchar wider than the rule's max. +- RISK-MIN: the rule has a min. +- No NUMERIC-SUSPECT or BROKEN rows were found. No Go number field carries a bare min/max, and every bound is a literal whole number. + +### RISK rows (first) + +| Repo | file:line | Field | Rule | Go type | Column | Verdict | Note | +|------|-----------|-------|------|---------|--------|---------|------| +| framework (summercms.go) | modules/cabana/contracts.go:49 | name (BackendUserRole) | required\|max:255\|unique:backend_user_roles | string | backend_user_roles.name TEXT | RISK-MAX | Winter's column is varchar(255), so imported data fits; only names saved through the Go admin could exceed | +| framework | modules/cabana/contracts.go:50 | code (BackendUserRole) | required\|max:255 | string | backend_user_roles.code TEXT | RISK-MAX | same | +| framework | modules/cabana/contracts.go:169 | login (BackendUser) | required\|max:255 | string | backend_users.login TEXT | RISK-MAX | same | +| framework | modules/cabana/contracts.go:170 | email (BackendUser) | required\|email\|max:255 | string | backend_users.email TEXT | RISK-MAX | same | +| framework | modules/cabana/backend_users_admin.go:479/482/483 | login, email (usersAdmin FormRules) | required\|max:255, required\|email\|max:255 | string | backend_users TEXT | RISK-MAX | same as above | +| framework | modules/cabana/backend_users_admin.go:477-479 | password (usersAdmin FormRules) | nullable\|min:4\|confirmed / required\|min:4\|confirmed | virtual string | not stored (bcrypt hash) | RISK-MIN | Only a typed password is checked (blank skips), so no stored-row risk. The message is now lagoon.Validate's English one (see follow-up 2) | +| sm-bm-app (jz/bm, app-owned) | models/checklist.go:36 | description | nullable\|max:10000 | *string | jz_bm_checklists.description TEXT | RISK-MAX | manage_records.go:234 validates the whole row on every PATCH, so an over-long description blocks unrelated edits too | +| sm-bm-app (jz/bm) | models/checklist_item.go:52 | notes | nullable\|max:10000 | *string | jz_bm_checklist_items.notes TEXT | RISK-MAX | same whole-row PATCH effect | +| sm-feedback-plugin | models/submission.go:80 | email | nullable\|email\|max:255 | *string | golem15_feedback_submissions.email TEXT | RISK-MAX | The public submit path (ValidateRequest SubmitRules) already enforces max:255, so only pre-port data could exceed | +| sm-oauthserver-plugin | models/client.go:77 | redirect_uris_text | nullable\|max:20000 | string (virtual, gorm `-`) | built from redirect_uris TEXT | RISK-MAX | A client whose joined redirect URIs exceed 20000 characters fails its next save | +| sm-oauthserver-plugin | models/client.go:79 | sector_identifier | nullable\|max:255 | *string | sector_identifier TEXT | RISK-MAX | | +| sm-oauthserver-plugin | models/settings.go:70 | custom_css | nullable\|max:20000 | *string | custom_css TEXT | RISK-MAX | The importer (console/import_settings.go settingsColumns) already skips values over 20000 characters, and branding.go caps at MaxCustomCSS. Only CSS saved through the admin before this change could exceed | +| sm-user-plugin | models/organisation.go:57 | name | required\|max:255 | string | organisations.name TEXT | RISK-MAX | | +| sm-user-plugin | models/organisation.go:59 | description | nullable\|max:5000 | *string | organisations.description TEXT | RISK-MAX | | +| sm-sociallogin-plugin | models/settings.go:68 | microsoft_tenant | nullable\|max:255 | *string | microsoft_tenant TEXT | RISK-MAX | tenantPattern `^[A-Za-z0-9.-]+$` sets no length, so a long value is possible but very unlikely | + +### SAFE rows + +| Repo | file:line | Field | Rule | Go type | Column | +|------|-----------|-------|------|---------|--------| +| framework | docs/examples/blog/models/post.go:34 | title | required\|max:255 | string | VARCHAR(255) | +| framework | docs/examples/blog/models/post.go:35 | slug | required\|max:255\|unique:acme_blog_posts | string | VARCHAR(255) | +| fonoteka.go (app plugin) | controllers/api/token_api_controller.go:37 | name | required\|max:255 | JSON request value | api_tokens.name VARCHAR(255) (create only; a >255 name is now a 422 instead of a DB error) | +| sm-bm-app (jz/bm) | models/checklist.go:35 | title | required\|max:255 | string | VARCHAR(255) | +| sm-bm-app | models/checklist_item.go:51 | title | required\|max:500 | string | VARCHAR(500) | +| sm-bm-app | models/checklist_item.go:53 | url | nullable\|max:2048 | *string | VARCHAR(2048) | +| sm-bm-app | models/checklist_item.go:54 | presentation_slug | nullable\|max:255 | *string | VARCHAR(255) | +| sm-bm-app | models/class_session.go:43 | room | nullable\|max:100 | *string | VARCHAR(100) | +| sm-bm-app | models/class_session.go:44 | kind | nullable\|max:100 | *string | VARCHAR(100) | +| sm-bm-app | models/class_session.go:45 | lecturer | nullable\|max:255 | *string | VARCHAR(255) | +| sm-bm-app | models/course.go:60 | code | nullable\|max:50 | *string | VARCHAR(50) | +| sm-bm-app | models/course.go:61 | name | required\|max:255 | string | VARCHAR(255) | +| sm-bm-app | models/course.go:63 | lecturer | nullable\|max:255 | *string | VARCHAR(255) | +| sm-bm-app | models/course.go:66 | assessment | nullable\|max:100 | *string | VARCHAR(100) | +| sm-bm-app | models/course.go:67 | elective_group | nullable\|max:100 | *string | VARCHAR(100) | +| sm-bm-app | models/course.go:68 | syllabus_url | nullable\|max:2048 | *string | VARCHAR(2048) | +| sm-bm-app | models/deadline.go:39 | title | required\|max:255 | string | VARCHAR(255) | +| sm-bm-app | models/material.go:54 | title | required\|max:255 | string | VARCHAR(255) | +| sm-bm-app | models/material.go:56 | source | nullable\|max:500 | *string | VARCHAR(500) | +| sm-bm-app | models/material.go:57 | url | nullable\|max:2048 | *string | VARCHAR(2048) | +| sm-bm-app | models/reading.go:39 | title | required\|max:500 | string | VARCHAR(500) | +| sm-bm-app | models/reading.go:40 | author | nullable\|max:255 | *string | VARCHAR(255) | +| sm-bm-app | models/reading.go:41 | source | nullable\|max:500 | *string | VARCHAR(500) | +| sm-bm-app | models/reading.go:42 | url | nullable\|max:2048 | *string | VARCHAR(2048) | +| sm-bm-app | models/tag.go:25 | name | required\|max:100 | string | VARCHAR(100) | +| sm-bm-app | models/tag.go:26 | slug | required\|max:100\|unique:jz_bm_tags | string | VARCHAR(100) | +| sm-oauthserver-plugin | models/client.go:76 | name | required\|max:255 | string | VARCHAR(255) | +| sm-oauthserver-plugin | models/settings.go:60 | site_name | nullable\|max:120 | *string | VARCHAR(120) | +| sm-oauthserver-plugin | models/settings.go:61-67 | page_background, surface_color, text_color, muted_text_color, border_color, accent_color, accent_text_color (7 rows) | nullable\|max:32 | *string | VARCHAR(32) | +| sm-oauthserver-plugin | models/settings.go:68 | font_css_url | nullable\|max:512 | *string | VARCHAR(512) | +| sm-oauthserver-plugin | models/settings.go:69 | font_family | nullable\|max:200 | *string | VARCHAR(200) | +| sm-paymentgatewaysubscriptions-plugin | models/subscription_plan.go:70 | code | required\|max:50\|unique:golem15_pgsub_subscription_plans | string | VARCHAR(50) | +| sm-paymentgatewaysubscriptions-plugin | models/subscription_plan.go:71 | name | required\|max:255 | string | VARCHAR(255) | +| sm-presentations-plugin | models/presentation.go:59 | title | required\|max:255 | string | VARCHAR(255) | +| sm-presentations-plugin | models/presentation.go:60 | slug | required\|max:255\|unique:golem15_presentations_presentations | string | VARCHAR(255) | +| sm-presentations-plugin | models/presentation.go:61 | context | nullable\|max:255 | *string | VARCHAR(255) | +| sm-presentations-plugin | models/presentation.go:65 | theme | nullable\|max:32 | *string | VARCHAR(32) | +| sm-presentations-plugin | models/slide.go:77 | title | max:255 | string | VARCHAR(255) | +| sm-presentations-plugin | models/slide.go:78 | css_class | max:60 | string | VARCHAR(60) | +| sm-presentations-plugin | models/slide.go:79 | section_label | nullable\|max:120 | *string | VARCHAR(120) | +| sm-quizzes-plugin | models/question.go:49 | category | nullable\|max:255 | *string | VARCHAR(255) | +| sm-quizzes-plugin | models/quiz.go:46 | title | required\|max:255 | string | VARCHAR(255) | +| sm-quizzes-plugin | models/quiz.go:47 | slug | required\|max:255\|unique:golem15_quizzes_quizzes | string | VARCHAR(255) | +| sm-quizzes-plugin | models/quiz.go:48 | context | nullable\|max:255 | *string | VARCHAR(255) | + +### Totals + +| Verdict | Plugin / app rows | Framework rows | Total | +|---------|-------------------|----------------|-------| +| SAFE | 48 (fonoteka 1, bm 23, oauthserver 11, pgsub 2, presentations 7, quizzes 4) | 2 (blog example) | 50 | +| RISK-MAX | 9 (bm 2, feedback 1, oauthserver 3, user 2, sociallogin 1) | 6 (contracts.go 4, usersAdmin FormRules login/email 2) | 15 | +| RISK-MIN | 0 | 1 (usersAdmin password, virtual) | 1 | +| NUMERIC-SUSPECT | 0 | 0 | 0 | +| BROKEN | 0 | 0 | 0 | + +The plugin and app rows add up to the 57 entries the scan found (48 + 9). + +Unchanged rules: +- `between` on non-numeric fields: 15 entries, all already length-checked in bytes and unchanged. 14 are literal: fonoteka style slug, journal tag slug, pgsub plan currency, user.go email/password/password_confirmation, user_group name, users_admin_controller email, api_controller 456/500/672/673/674/771. One more is built at runtime: users_admin_controller password `between:,255`, a valid whole-number bound. Separately, 4 `between` entries with integer/numeric are numeric ranges. +- min/max together with `string`/`url`: 17 entries (feedback settings and submission, journal settings), unchanged since 261104-f4x. +- min/max together with `numeric`/`integer`: 26 entries, unchanged. + +Behaviour notes: +- Direct non-admin callers: `sm-bm-app` `manage_records.go:234` validates the whole row on every API PATCH, and fonoteka `token_api_controller.go` validates a request body. Their frontends now get a 422 for over-long values. In bm this happens on any PATCH of a row that already holds one. +- Duplicate length checks, candidates for later simplification: `sm-oauthserver-plugin/console/import_settings.go` `settingsColumns` repeats the settings Rules() maxima, and `classes/branding.go` `MaxCustomCSS` caps the CSS. The bound in `storePassword` (`adminPasswordMinChars`) now repeats the FormRules min:4. +- Not a finding of this change: `sm-paymentgateway-plugin` `Currency.Rules()` and `Order.Rules()` use `size:3`, which lagoon.Validate does not implement (an unrecognized-rule error). Neither is reached today: there is no currency screen, and the orders FormRules return an empty map and FormBeforeUpdate refuses saves. Logged for awareness, nothing changed. +- `ruleLength` does not measure `sql.NullString` or other struct wrappers. No audited rule uses one. + +## Draft v0.6.1 release-note line + +**Changed:** `lagoon.Validate` now checks `min` and `max` as a length on every field that is not `integer` or `numeric`, the way Laravel does: characters for a string, items for a list. A stored value longer than its rule's `max` fails its next admin save until it is shortened. A `min`/`max` bound that is not a whole number is now an error. + +## Deviations from Plan + +None. The plan was executed as written. One small implementation detail: `ruleLength` also measures `reflect.Uintptr`, so every unsigned kind is covered (commit 335f8e6). Neither cabana file had uncommitted edits from other sessions, so both were edited. + +## Follow-ups for the user + +1. A non-numeric `between` counts bytes (`len(s)`), not characters (Laravel's `mb_strlen`), so Polish text reaches the limit early. Example: `between:3,64` on journal tag slugs, user names and group names. +2. cabana passes a nil translator to lagoon.Validate (crud.go:882, relation.go:1220, relation_child.go:126, settings.go:267), so every model-rule message in the admin is English. A too-short password in a pl admin used to get storePassword's translated message; it now gets lagoon.Validate's English "The password must be at least 4 characters.". +3. pl `validation.yaml` still has English lines, all for rules this task does not touch: dimensions, distinct, file, in_array, json, present, required_unless. +4. The pl `string` translation deliberately differs from the PHP backend, whose pl response is English (the file header records this). +5. The source todo `.planning/todos/pending/2026-11-04-lagoon-validate-min-max-length-on-text-fields.md` can move to done. +6. Before tagging v0.6.1, operators of apps with RISK-MAX rows (oauthserver custom_css/redirect URIs, bm checklist descriptions/notes, user organisations, backend roles/users) may want to check for over-long stored values. For example: `SELECT id FROM jz_bm_checklists WHERE char_length(description) > 10000`. + +## Gates + +Docker was reachable directly; `sudo -u golem` was not needed. + +| Gate | Result | +|------|--------| +| `go vet ./...` | clean | +| `go test -short -count=1 ./...` | all ok | +| `go test -count=1 ./modules/lagoon/... ./modules/cabana/... ./modules/phrasebook/...` (Docker, testcontainers) | ok: lagoon 47s, lagoon/attach 84s, cabana 137s, phrasebook | +| `go test -count=1 ./cmd/summer -run TestDocsTree` | ok | +| `examples/hello`: `go test -count=1 ./...` | ok | + +No sibling repo was modified; the audit was read-only. The pre-existing dirt in `../sm-oauthserver-plugin/go.work` (dated 2026-10-23), `../sm-journal-plugin/go.work.sum` and `../sm-grzybyfunkcjonalne-app/.gsd/` was already there. The `examples/hello` test regenerates `examples/hello/plugins.gen.go`, setting FrameworkVersion from v0.5.3 to v0.6.0. That file was restored with `git checkout --`, so it stays uncommitted. Follow-up: either commit the bumped stamp or stop the test from writing into the tree. + +## Known Stubs + +None. + +## Self-Check: PASSED + +- FOUND: modules/lagoon/validate.go, modules/lagoon/validate_test.go, modules/cabana/settings_string_url_test.go, modules/cabana/backend_users_admin.go, modules/lagoon/README.md, docs/database/casts-and-validation.md, modules/phrasebook/lang/pl/validation.yaml, modules/phrasebook/lang/pl/validate.yaml +- FOUND: 335f8e6, 1452514 (`git rev-list --count 0785336..HEAD` = 2) diff --git a/.planning/todos/pending/2026-11-04-lagoon-validate-min-max-length-on-text-fields.md b/.planning/todos/completed/2026-11-04-lagoon-validate-min-max-length-on-text-fields.md similarity index 100% rename from .planning/todos/pending/2026-11-04-lagoon-validate-min-max-length-on-text-fields.md rename to .planning/todos/completed/2026-11-04-lagoon-validate-min-max-length-on-text-fields.md