statum-issues.md 19 KB

Statum issues — fix in this repository

Friction points from the Percipio migration that should be addressed directly in Statum (client JS, GObjectMapping, action/handler base classes, DirectiveBuilder, TopicSystem, crypto providers, key config, and docs). Items that are actually upstream invercargill-json bugs (numeric/bool serialization crashes) are in invercargill-upstream-issues.md; this file references them only where a Statum-side mitigation is also worth doing. Item numbers in parentheses refer to the original Percipio friction report.


Already tracked (don't drop)

S-0a. stm-data-form — let a trigger reference a sibling <form>'s inputs

  • What: statum.js collectData only reads form inputs when the trigger is a <form>, so a co-equal "Save draft" <button> sent an empty body.
  • Fix: an attribute letting an action element (e.g. a button) reference a <form> whose named inputs to send with the action. (Already covered by another report — keep it on the list.)

S-0b. stm-selected — set the selected value on a <select>

  • What: stm-attribute.value on a <select> only calls setAttribute('value', …), which does not change the selected option.
  • Fix: a stm-selected attribute (or make stm-attribute.value on a <select> set the selected option). Worked around today with per-option stm-attribute.selected="q.skill == '…'". (Already covered — keep it.)

Client / JS side (js/statum.js)

S-1. Slot names that shadow JS globals silently break stm-if → blank page — SEVERE (orig #1)

  • What: naming a slot eval made stm-if="… eval == null …" evaluate wrong. Inside with(scope){ … }, an identifier not in scope resolves to the global (eval is the built-in), so eval == null was always false. Result: fully blank page, no console error.
  • Impact: hours of debugging with zero diagnostics.
  • Fix: reserve/validate slot type names against JS globals & reserved words at registration (warn or reject), and/or make the expression evaluator resolve unknown identifiers to undefined instead of falling through to window (this is the same evaluator change as S-2).

S-2. Referencing an unset slot makes the whole expression silently falsy — SEVERE (orig #2)

  • What: evalExpr is new Function('scope','with(scope){try{return (EXPR);}catch(e){return undefined;}}'). If EXPR mentions a slot that isn't held, the reference throws ReferenceError, the inner catch returns undefined, so stm-if is falsy and stm-text is empty — silently. unsetSlot == null does not evaluate to true (as plain JS undefined == null would), because the entire expression is swallowed by the catch.
  • Impact: the challenge page was blank because stage-A's stm-if mentioned evaluation (unset for a fresh attempt). Very hard to spot — no error.
  • Fix: use an evaluator that resolves missing scope members to undefined (not throw), so unsetSlot == nullundefined == nulltrue. Only property access on a truly-undefined value should be caught. Pair with the debug mode in S-16.

S-3. [hidden] is defeated by ordinary author CSS — SEVERE, sneaky (orig #3)

  • What: Statum toggles stm-if/stm-for branches with the hidden attribute, but the UA [hidden]{display:none} is lower specificity than a normal rule like .challenge-list li{display:flex}, so the hidden loop holder leaked as a persistent empty row.
  • Impact: "phantom empty entry" with no clue it was a CSS-specificity issue.
  • Fix: inject [hidden]{display:none !important} from statum.js itself (so it can't be overridden), or switch to a dedicated stm-hidden class with !important. At minimum, document this prominently.

S-4. stm-text on an editable <input>/<textarea> resets the user's typing (orig #4)

  • What: binding a form field's value with stm-text is one-way state→DOM; on any re-evaluation (e.g. after a co-equal action's notify) it wipes what the user just typed, resetting it to the stale state value.
  • Impact: "Save draft clears my answers" until worked around by echoing the saved values back via an update.
  • Fix: an "initial-value / set-once" binding (e.g. stm-value) that sets the value when first bound and then leaves it alone while the field is editable (or focused), and/or document the editable-field pattern.

S-5. collectData ignores the enclosing form for non-<form> triggers (orig #5)

  • What: a <button stm-action=…> inside a <form> collects only its own stm-data-* attributes, not the form's named inputs.
  • Impact: multiple data-collecting actions on one form are impossible today without a hidden intent field + onclick hack.
  • Fix: resolved by S-0a (stm-data-form).

S-6. No first-class spinner/busy binding (orig #6)

  • What: setBusyState adds stm-busy-class classes to the action element and disables children, but showing a spinner requires hand-rolled <span class="spinner"> + CSS keyed off .btn-loading.
  • Fix: ship a built-in stm-spinner (inject a spinner while busy).

Server / Vala side (Statum)

S-7. GObjectMapping cannot serialise array/list/collection fields — FOUNDATIONAL (orig #10)

FIXED (2026-08-31, WP1 server-side effort). GObjectMapping.to_properties now serialises string[] properties as JSON arrays (read-back supported via from_properties) and Object-typed properties holding an Invercargill collection (Series<T>/Lot<T>/Vector<T>) as JSON arrays — registered Object elements map via the existing mapper registry, fundamental elements as primitives. Note: int[]/int64[]/double[]/bool[]/Object[] cannot exist as GObject properties at all on Vala 0.56 (the compiler drops them), so string[] + typed collections are the supported shapes. Collection read-back is write-side only (element types are erased from the property GType) — documented prominently in the to_properties/from_properties valadoc. update_held now carries such array fields forward instead of dropping them (see S-14).

  • What: Object[] / Lot<T> / Gee.List properties are silently skipped by to_properties (src/GObjectMapping.vala:80-83 skips unregistered Object-typed properties; collections become non-primitives). Every list-bearing slot must be hand-built as a PropertyDictionary with a JsonElement array, and set_typed<T> / update_held<T> are unusable for any model containing a list.
  • Impact: the dominant source of boilerplate for a real (list-heavy) app; wrote a custom StateJson.list<T>(lot) helper; update_held effectively dead.
  • Fix: support list/collection fields in GObjectMapping (emit JSON arrays), and/or ship a built-in State.json_array<T>(lot) / State.nested(model) helper so the "list of row models" pattern is one call instead of ~10 lines of PropertyDictionary plumbing. (Independent of the upstream numeric/bool bugs — Invercargill can already build array JsonElements.)

S-8. TypedStatumAction<TPrivate>.request_private is broken (typeof(TPrivate)void) (orig #11)

FIXED (2026-08-31, WP1 server-side effort). Root cause confirmed: valac carries generic type arguments as hidden construct properties, so actions instantiated reflectively (scope.resolve_type — the normal path) get G_TYPE_NONE for typeof(TPrivate); the erased (TPrivate) transfer also dropped the only reference to the materialised model. Fix: TypedStatumAction now has a protected virtual Type private_type (overridden in subclasses to name the private model — the example's BumpCounterAction shows the pattern), an eroded type logs a warning naming the action type instead of crashing, and the model is held via a plain Object field so the reference survives the erased-generic return. Verified by a cross-namespace reproduction in tests/TestMain.vala.

  • What: request_private calls GObjectMapping.from_properties(typeof(TPrivate), …) (src/StatumHandlers.vala:196), but at runtime typeof(TPrivate) resolves to void (Vala generic erasure) → g_object_new_with_properties: assertion G_TYPE_IS_OBJECT failed → private data maps to null. Had to bypass it and read request.action_private manually.
  • Impact: every typed action's request_private silently returned null.
  • Fix: work around the Vala typeof(T)-in-generics issue — pass the GType explicitly from the subclass (e.g. an abstract Type private_type override) or capture it another way — so request_private works as documented. (The example's counter action apparently works — worth diffing why; possibly same-compile-unit vs cross-namespace type registration.)

S-9. Action-private decryption failures are silent (orig #12)

Partially FIXED (2026-08-31, WP1 server-side effort): decryption failures now log a warning() naming the seal namespace and action type, and request_private logs when the blob cannot be materialised. The requires_valid_private default for plain actions is unchanged (still false).

  • What: decrypt_action_private (src/StatumHandlers.vala:151-164) returns an empty PropertyDictionary on any failure instead of throwing/logging, and requires_valid_private defaults to false (src/StatumHandlers.vala:86), so a mis-sealed action just runs with an empty context (attempt_id == 0).
  • Impact: "retry button does nothing / bad_private" bugs are invisible.
  • Fix: default requires_valid_private to true for plain actions too (it already is for TypedStatumAction, StatumHandlers.vala:185), and log decryption failures with the namespace so mismatches are diagnosable.

S-10. The topic system can't push list payloads (orig #13)

  • What: a topic's StateModifier.apply (src/TopicSystem.vala:111-143) serialises the derived state via GObjectMapping.to_properties (src/TopicSystem.vala:136), which can only carry scalar/registered-object state — not a per-question results list. Fell back to a manual state_service.update(key, …) with a stashed slot key on the background worker.
  • Impact: couldn't use topics for live eval results.
  • Fix: let topic modifiers author a full PropertyDictionary (with JsonElement arrays), or provide a blessed "background worker → held slot" push keyed by a business id, so "job finishes → update the client's view" doesn't require hand-stashing slot keys. (Cascades from S-7; fixing S-7's list support removes most of the pain.)

S-11. No built-in auth / identity primitive (orig #14)

  • What: identity is entirely DIY — build an auth SESSION slot, read it back from request.held in every handler, derive admin from the signed public role, hand-roll guards.
  • Impact: getting the slot shape, scope, key persistence, and the "rebuild-each-entrypoint" pattern right was a large part of the effort.
  • Fix: a blessed "identity/auth slot" pattern + helpers (request.current_user, require_admin(), a server-side guard that issues a navigate/error directive) and documented guidance on SESSION vs DEVICE scope and static-key persistence (see S-12).

S-12. Static crypto keys are required for persistent sessions — but only a console whisper (orig #15)

FIXED (2026-08-31, WP1 server-side effort). The single-line whispers in create_signing_provider/create_encryption_provider are now prominent multi-line startup warnings (still warnings, not errors) that spell out the consequence and point at statum-genkeys.

  • What: without web-config.json static keys, every signed SESSION/DEVICE slot is invalidated on restart (ephemeral keys) → all users logged out. There is only a quiet warning(...) at startup (src/Statum.vala:86 and src/Statum.vala:104).
  • Fix: fail loud (or a very prominent warning) when slots are used for identity without persistent keys, or auto-generate + persist keys by default (the statum-genkeys tool already exists in tools/).

S-13. to_properties returns Properties, forcing a PropertyDictionary cast (orig #16, primary)

FIXED (2026-08-31, WP1 server-side effort). GObjectMapping.to_properties now returns PropertyDictionary directly; all Statum callers updated.

  • What: GObjectMapping.to_properties() returns the Properties interface (src/GObjectMapping.vala:57); adding a list/nested value requires casting to the concrete PropertyDictionary to use its indexer.
  • Impact: fragile, undocumented cast.
  • Fix: have to_properties return PropertyDictionary directly (it already constructs one internally at GObjectMapping.vala:58). The complementary upstream enhancement (give Properties a typed setter) is tracked as invercargill-upstream-issues.md I-4.

S-14. update_held<T> is effectively unusable for real models (orig #17)

FIXED (2026-08-31, WP1 server-side effort). With S-7's array support, string[] fields round-trip through update_held, and JSON-array fields the typed model cannot read back (collection-typed properties) are carried forward unchanged instead of vanishing (GObjectMapping.preserving_arrays; StateService.update_global_typed behaves the same). Rebuilding from the source of truth remains the canonical pattern for mutating collection contents.

  • What: update_held (src/DirectiveBuilder.vala:73) round-trips through to_properties/from_properties, so it can't preserve list fields — they'd be dropped. The real pattern is "reload from DB and update(key, State)". update_held looks temptingly usable until your list vanishes.
  • Impact: dead convenience API for any real slot.
  • Fix: either lift the list limitation (depends on S-7, making update_held safe) or document loudly that update_held is scalar-only and the canonical mutation pattern is rebuild-from-DB + update.

S-15. Page route params don't reach actions (orig #18)

  • What: actions resolve to GUID endpoints, so a page's {slug}/{id} aren't in request.route_params. The documented workaround is to seal context into the action's private data — verbose and easy to forget.
  • Impact: every parametrized action needs per-action sealing boilerplate.
  • Fix: a first-class "page context" mechanism that actions can read without per-action sealing (e.g. automatically include the originating page's resolved route params in StatumRequest, which is populated at src/StatumHandlers.vala:125-132).

Cross-cutting / DX

S-16. Blank pages with zero diagnostics — SEVERE (orig #19)

  • What: the combination of S-1, S-2 and S-3 produces blank pages (not even the preloader) with no console error and no server error. The hardest class of bug.
  • Fix: a "statum debug" mode that logs: which slots are in scope, which stm-if evaluated falsy and why, and any with(scope) ReferenceErrors.

S-17. X-Statum-Slot / X-Statum-Private headers are hard to construct for testing (orig #20)

  • What: figuring out the reference form (key=…; as_at=…) vs base64-frame form, and that action private travels on X-Statum-Private, took reverse-engineering the JS client.
  • Fix: a short "testing Statum handlers with curl" recipe in the docs, and/or a helper that prints the exact headers a client would send.

S-18. Documentation gaps vs the example (orig #21)

  • What: the example app (example/) is minimal (counter + a scalar announcement). It doesn't exercise list-bearing slots, encrypted private data, update_held, typed action private, editable form fields, selects, or background pushes — i.e. exactly the things that bit.
  • Fix: a richer example (or a "patterns" doc) covering lists + private + actions.

Fixed during the 2026-08-31 stack effort (not in the Percipio report)

Bugs found and fixed while landing WP1–WP4 of the stack refactor; recorded here so they stay tracked. All are fixed.

S-19. from_properties crashed reading cached int64 ValueElements

FIXED (2026-08-31, WP1 server-side effort). GObjectMapping.from_properties now converts cached ValueElements through the native GValue transformation (Value.type_transformable/transform) instead of the generic Element.to_value dispatch, whose int64/uint64 branches mis-handle the boxed representation and crashed on read-back. Verified by Statum/tests round-trips.

  • What: a slot read back through from_properties whose properties still held server-cached ValueElements (rather than re-parsed JSON) crashed in Element.to_value on int64 values — the read-side twin of the upstream I-1 write-side crash.
  • Fix: element_value in src/GObjectMapping.vala — the raw scalar never passes through the generic element dispatch when a native transform exists.

S-20. EncryptionProvider.read_properties use-after-free

FIXED (2026-08-31, WP1 server-side effort). read_properties previously returned a JsonObject wrapping memory owned by a temporary JsonElement; once that element was finalized the returned properties pointed at freed memory. It now re-parses the decrypted JSON into a PropertyDictionary it owns, with values held by fresh JsonElements. (Also noted under upstream I-2, where the crash was first chased.)

  • What: the unseal path (src/Cryptography/EncryptionProvider.vala) handed back a view over a temporary parse tree; consumers reading it later read freed memory.
  • Fix: re-parse into an owned PropertyDictionary.

S-21. Spry.Authentication.UserPermissionEntity was missing its Object base

FIXED (2026-08-31, WP2 Spry rewrite). Found while wiring the rewritten Spry (recorded here because the effort's issue log lives in these files): the entity class was declared without : Object, so its GType was fundamental and it could not be instantiated — the user-management wiring crashed at startup. Fixed by deriving from Object like every other entity.


Top 5 ranked by pain (Statum-side; see upstream file for the #1 process crash)

  1. S-2 unset-slot → silent falsy → blank page — no diagnostics; affects every page referencing an optional slot.
  2. S-7 no list serialisation — makes the typed-model API unusable for real apps and forces verbose PropertyDictionary plumbing everywhere.
  3. S-3 [hidden] vs author CSS — sneaky, app-wide risk, trivial library fix.
  4. S-8 broken request_private — a documented API that silently returns null.
  5. S-16 blank pages with zero diagnostics — the hardest bug class.

Note: the #1 overall pain item — the int64/bool segfault — is upstream (invercargill-upstream-issues.md I-1/I-2); it crashes the whole process and cannot be fixed in Statum.