Selaa lähdekoodia

Fix GObjectMapping serialization (scalars, string[], collections), TypedStatumAction private GType, encryption lifetimes; add test suite and issue notes

clanker 1 viikko sitten
vanhempi
sitoutus
4e4f108eab

+ 7 - 0
example/Actions.vala

@@ -56,9 +56,16 @@ namespace Example {
      * `POST /_statum/action/{guid}` — bumps the counter by the typed private
      * `delta`, using the typed {@link DirectiveBuilder.update_held} so the
      * `bump` action reference is preserved automatically across the update.
+     *
+     * Actions are instantiated by GType (no generic type argument), so
+     * `private_type` is overridden to name the private model — otherwise
+     * `typeof(CounterPrivate)` erodes to `void` and {@link request_private}
+     * yields `null`.
      */
     public class BumpCounterAction : TypedStatumAction<CounterPrivate> {
 
+        protected override Type private_type { get { return typeof(CounterPrivate); } }
+
         public override async DirectiveBuilder handle() throws GLib.Error {
             var delta = request_private != null ? ((!)request_private).delta : 1;
             return directives().update_held<CounterPublic>("counter", c => c.value += delta);

+ 1 - 1
example/HomeEntrypoint.vala

@@ -80,7 +80,7 @@ namespace Example {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var bump_private = new CounterPrivate();
-            bump_private.delta = 1;
+            bump_private.delta = 2;
             var bump_ref = action_registry.author_private<BumpCounterAction, CounterPrivate>(bump_private);
 
             var counter = new CounterPublic();

+ 1 - 1
example/counter-card.html

@@ -8,6 +8,6 @@
 </head>
 <body pstm-materialise-as="section" class="counter-card">
     <p>Counter: <span stm-text="counter.value">0</span></p>
-    <button stm-action="counter.bump">Bump +1</button>
+    <button stm-action="counter.bump">Bump +2</button>
 </body>
 </html>

+ 27 - 1
getting-started.md

@@ -228,6 +228,30 @@ Private slot data is encrypted (X25519-Seal + Ed25519 signature) under a
 namespace. For action private data, the namespace is the action type name (so
 one action cannot decrypt another's private blob).
 
+### Typed model serialization
+
+`GObjectMapping` (used by every typed slot/action API) serialises GObject
+models by property introspection:
+
+- Scalar properties (`string`, `int`, `int64`, `long`, `bool`, `double`,
+  enums) serialise as type-correct JSON primitives and round-trip — including
+  `int64` and `bool`, on both the public snapshot path and the encrypted
+  private path (`set_private_typed` / `EncryptionProvider`).
+- `string[]` properties serialise as JSON arrays and read back into the model.
+- `Object`-typed properties holding an Invercargill collection
+  (`Enumerable`, e.g. `Series<T>`/`Lot<T>`/`Vector<T>`) serialise as JSON
+  arrays — registered `Object` elements (see `register_mapper`) map as nested
+  objects, fundamental elements as primitives. This is write-side only: a
+  property's GType does not record the element type, so collections cannot be
+  read back — rebuild such models from their source of truth (e.g. the
+  database). `update_held` carries array fields it cannot map forward
+  unchanged instead of dropping them.
+
+Note the Vala 0.56 toolchain limitation: `int[]`, `int64[]`, `double[]`,
+`bool[]` and `Object[]` cannot exist as GObject properties at all (the
+compiler drops them), so lists in typed models are `string[]` properties or
+`Enumerable` collections of objects.
+
 ---
 
 ## Entrypoint Handlers
@@ -512,7 +536,9 @@ Started via `directives()` on any handler. Fluent, self-returning:
 ### `GObjectMapping`
 
 Maps GObject models to/from `Properties` via introspection (no per-model
-mapper). ActionDto fields round-trip as nested JSON via the mapper registry.
+mapper). ActionDto fields round-trip as nested JSON via the mapper registry;
+scalars (incl. `int64`/`bool`) and `string[]` round-trip, and collection
+properties serialise as arrays — see [Typed model serialization](#typed-model-serialization).
 
 | Method | Description |
 |---|---|

+ 142 - 0
invercargill-upstream-issues.md

@@ -0,0 +1,142 @@
+# Upstream issues — Invercargill / `invercargill-json`
+
+Friction points surfaced by the Percipio migration that must be fixed **upstream of
+Statum**, i.e. in the `invercargill-1` / `invercargill-json` libraries (declared as
+dependencies in `meson.build`).
+
+All of the items below are correctness/crash bugs in Invercargill's JSON mapping
+layer — specifically the `ValueElement` → `from_element` → `JsonElement` /
+`from_properties` path. Statum merely *calls* these APIs (see call sites below), so
+it can only work around them, not fix them. Item numbers in parentheses refer to
+the original Percipio friction report.
+
+---
+
+## Severe — process-crashing serialization
+
+### I-1. `int64` values segfault during JSON serialisation (orig #7)
+> **RESOLVED (verified 2026-08-31, WP1 server-side effort).** The dispatch fix
+> landed upstream in Invercargill-Json commit `fc38c4c` ("Fix bool and int
+> serialisation issues") — `from_element` now reads the raw `GValue` behind a
+> `ValueElement` and dispatches on the fundamental type. This effort did not
+> need to modify Invercargill-Json; `Statum/tests` round-trips `int64`
+> (full 2^53+ precision) through both the public snapshot path and
+> `EncryptionProvider.author_properties`/`read_properties` (the Percipio login
+> crash path).
+
+
+- **What happens:** `GObjectMapping.to_properties()` wraps each scalar GObject
+  property in `new ValueElement(value)` (`src/GObjectMapping.vala:85`). When that
+  `PropertyDictionary` is later serialised via `JsonElement.from_properties()` —
+  e.g. in `EncryptionProvider.author_properties`
+  (`src/Cryptography/EncryptionProvider.vala:77`) or in the snapshot-to-client
+  path — the `int64` branch of `from_element` crashes at
+  `node.set_int(element.as<int64?>())`.
+- **GDB:** backtrace confirmed the segfault in
+  `encryption_provider_author_properties` → `JsonElement.from_properties`.
+- **Impact:** catastrophic. The login action hard-crashed the **whole server
+  process** (segfault) until every `int64` was purged from slot state. Single
+  most dangerous footgun.
+- **Fix (Invercargill):** repair the `int64` path in
+  `from_element` / `ValueElement.as<int64?>()` so a legitimately-held `int64`
+  `ValueElement` produces a valid `Json.Node` via `set_int` instead of crashing.
+
+### I-2. `bool` values segfault on the encryption serialisation path (orig #9)
+> **RESOLVED (verified 2026-08-31, WP1 server-side effort).** Same upstream fix
+> as I-1 (Invercargill-Json `fc38c4c`); verified through the
+> `set_private_typed` → `author_properties` → `read_properties` path by
+> `Statum/tests`. A related Statum-side lifetime bug found while testing this
+> path — `EncryptionProvider.read_properties` returned a `JsonObject` wrapping
+> memory owned by a temporary `JsonElement` (use-after-free) — was fixed in
+> Statum; it now returns values re-parsed into a `PropertyDictionary` it owns.
+
+
+- **What happens:** the same `from_properties`/`from_element` family crashes on a
+  `bool` `ValueElement`. The **public** path does not crash on bools (it
+  serialises the snapshot DTO through a different, robust serialiser), but the
+  **encryption/private** path — `set_private_typed`
+  (`src/DirectiveBuilder.vala:56`) → `to_properties` →
+  `EncryptionProvider.author_properties`
+  (`src/Cryptography/EncryptionProvider.vala:77`) → `from_properties` — does.
+- **Impact:** had to strip all `bool`s out of `AuthPrivate` and derive admin
+  status from the signed public `role` instead.
+- **Fix (Invercargill):** make `from_element`/`from_properties` handle `bool`
+  `ValueElement`s without crashing, so the private/encryption path is as robust
+  as the public path. (Same root cause as I-1: the `ValueElement` type dispatch
+  in `from_element` is incomplete.)
+
+## Correctness — wrong wire type
+
+### I-3. `int` (32-bit) serialises as a JSON **string**, not a number (orig #8)
+> **RESOLVED (verified 2026-08-31, WP1 server-side effort).** Same upstream fix
+> as I-1 (Invercargill-Json `fc38c4c`); `Statum/tests` asserts `int` fields
+> serialise as unquoted JSON numbers and round-trip on both paths.
+
+
+- **What happens:** `to_properties` on a GObject with an `int` field produces a
+  JSON string (`"best_score": "-1"`) instead of a number. The int32 branch in
+  `from_element` is not reached for `ValueElement`s; it falls through to the
+  `assignable_to<string>()` transform fallback.
+- **Impact:** slot numbers render as quoted strings; server-side `get_int` on a
+  round-tripped value fails because the encrypted JSON stored a string. JS
+  coercion mostly hides it for display, but it breaks round-tripping.
+- **Fix (Invercargill):** make `from_element` emit numbers for numeric
+  `ValueElement`s consistently (reach the int32/int64 branches rather than the
+  string fallback).
+
+> Note: I-1, I-2 and I-3 share a single root cause — the `ValueElement` →
+> `from_element` type dispatch is unreliable for non-string scalars. Fixing the
+> dispatch holistically would close all three at once.
+
+## Ergonomic / API
+
+### I-4. `Properties` interface has no typed setter / `set_json` (orig #16, secondary)
+> **Mitigated Statum-side (2026-08-31):** `GObjectMapping.to_properties` now
+> returns `PropertyDictionary` directly (see `statum-issues.md` S-13), so
+> Statum callers no longer need the cast. The upstream interface enhancement
+> remains open.
+
+- **What happens:** `GObjectMapping.to_properties()` returns the `Properties`
+  interface (`src/GObjectMapping.vala:57`), but adding a list/nested value
+  requires casting to the concrete `PropertyDictionary` to use its indexer
+  (`(PropertyDictionary) to_properties(pub)`).
+- **Impact:** fragile, undocumented cast.
+- **Fix (Invercargill):** give the `Properties` interface a typed setter (e.g.
+  `set_json(string key, Element element)`) so callers need not cast to
+  `PropertyDictionary`.
+  - The **primary** mitigation for orig #16 is Statum-side (see
+    `statum-issues.md` S-13: change `to_properties` to return `PropertyDictionary`
+    directly); this item is the upstream API enhancement that would remove the
+    cast at the source.
+
+### I-5. `PropertyMapperBuilder`'s default constructor closure dangled after build
+> **RESOLVED (2026-08-31, WP1 server-side effort).** Fixed in this repository's
+> copy of Invercargill as a pure bug fix — no API change. The builder now
+> installs no default constructor closure, and
+> `PropertyMapper.materialise()` falls back to `Object.new(typeof(T))` when no
+> constructor was set explicitly.
+
+- **What happens:** `PropertyMapperBuilder<T>`'s constructor defaulted its
+  `constructor` field to a closure capturing the builder itself. The builder
+  is usually transient (built and dropped inside `PropertyMapper.build_for`),
+  while the returned `PropertyMapper` lives on, so every later
+  `materialise()` call went through a closure pointing at freed memory — a
+  use-after-free that surfaced as crashes/garbage instances when mappers were
+  resolved and used after the building scope had ended.
+- **Impact:** latent lifetime bug on every mapper built via
+  `PropertyMapper.build_for` that did not call `set_constructor` explicitly
+  (the common case).
+- **Fix (Invercargill):** leave the builder's `constructor` null by default
+  and let `materialise()` construct through `Object.new(typeof(T))` — no
+  closure outlives the builder.
+
+---
+
+## Reproduction call sites (in Statum, for the Invercargill maintainers)
+
+| Bug | Statum call site | Invercargill symbol that crashes/misbehaves |
+|-----|------------------|---------------------------------------------|
+| I-1 | `GObjectMapping.vala:85` (`new ValueElement(value)`) → `EncryptionProvider.vala:77` (`JsonElement.from_properties`) | `ValueElement.as<int64?>()` → `Json.Node.set_int` |
+| I-2 | `DirectiveBuilder.vala:56` → `EncryptionProvider.vala:77` | `from_properties` / `from_element` on a `bool` |
+| I-3 | `GObjectMapping.vala:85` → any `from_properties`/`stringify` | `from_element` falls through to `assignable_to<string>()` for int32 |
+| I-4 | `GObjectMapping.vala:57` (return type) | `Properties` interface lacks a setter |

+ 52 - 3
js/statum.js

@@ -139,6 +139,30 @@
    */
   var exprCache = new Map();
 
+  /**
+   * Wrap a scope for expression evaluation so bare identifiers resolve in a
+   * controlled order: names on the scope (slot types, loop variables,
+   * `stm-def-*` locals) win; names missing from the scope but present on
+   * `globalThis` (`Math`, `parseInt`, `String`, …) forward to that global;
+   * anything else resolves to `undefined` instead of throwing a
+   * `ReferenceError` or leaking an unintended global. The `has` trap claims
+   * every name so `with` never falls through around the proxy, and the inner
+   * try/catch of the compiled expression still turns property access on a
+   * resolved `undefined`/`null` into `undefined` for that expression alone.
+   * @param {object} scope
+   * @returns {Proxy<object>}
+   */
+  function evalScope(scope) {
+    return new Proxy(scope, {
+      has: function () { return true; },
+      get: function (target, name) {
+        if (name in target) return target[name];
+        if (typeof name === 'string' && name in globalThis) return globalThis[name];
+        return undefined;
+      }
+    });
+  }
+
   /**
    * Compile (and cache) an expression string into a function of `scope`.
    * Uses `with` so that arbitrary type names and loop variables are resolved
@@ -153,8 +177,10 @@
     var fn;
     try {
       // Function-constructor bodies are non-strict by default, so `with` is
-      // permitted. The inner try/catch turns reference errors into `undefined`
-      // so a missing path simply yields no value.
+      // permitted. The inner try/catch turns runtime errors (property access
+      // on an unset slot's `undefined`) into `undefined` so a missing path
+      // simply yields no value; missing identifiers already resolve to
+      // `undefined` via the evalScope proxy.
       fn = new Function('scope', 'with(scope){try{return (' + expr + ');}catch(e){return undefined;}}');
     } catch (e) {
       fn = function () { return undefined; };
@@ -172,7 +198,7 @@
   function evalExpr(expr, scope) {
     if (expr == null || expr === '') return undefined;
     try {
-      return compileExpr(expr)(scope || {});
+      return compileExpr(expr)(evalScope(scope || {}));
     } catch (e) {
       return undefined;
     }
@@ -1750,6 +1776,25 @@
   // Initialization
   // ---------------------------------------------------------------------------
 
+  /**
+   * Inject the stylesheet that makes the `hidden` attribute authoritative.
+   * Statum toggles `stm-if`/`stm-else` branches and loop holders via
+   * `hidden`, whose user-agent rule loses specificity battles to ordinary
+   * author CSS (a rule like `li { display: flex }` let hidden loop holders
+   * leak as phantom rows). Idempotent: skipped when the stylesheet is
+   * already present.
+   */
+  function injectHiddenStyle() {
+    var id = 'statum-hidden-style';
+    if (document.getElementById(id)) return;
+    var head = document.head;
+    if (!head) return;
+    var style = document.createElement('style');
+    style.id = id;
+    style.textContent = '[hidden]{display:none !important}';
+    head.appendChild(style);
+  }
+
   /**
    * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
    * are intended for server-side pre-rendering and should never reach the
@@ -1785,6 +1830,10 @@
     if (initialized) return;
     initialized = true;
 
+    // Before the first render and the boot visibility toggling, so `hidden`
+    // wins over author CSS from the very first paint.
+    injectHiddenStyle();
+
     var body = document.body;
     if (body) {
       if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint');

+ 1 - 0
meson.build

@@ -32,3 +32,4 @@ add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 subdir('tools')
 subdir('src')
 subdir('example')
+subdir('tests')

+ 5 - 1
model.md

@@ -291,6 +291,8 @@ The Javascript library also has some functionality inspired by HTMX using attrib
 
 Attribute values used as predicates (`stm-if`, `stm-else-if`, `stm-class.x`) are evaluated as JavaScript expressions against a scope built from the current state. Because the page's HTML is trusted, expressions are evaluated directly rather than via a restricted parser. In this scope each type name is bound to that slot's current `public` object, plus any in-scope loop variables. For example, `stm-if="typeName.level > 5"` evaluates `typeName.level > 5` with `typeName` bound to that type's `public` data.
 
+Bare identifiers resolve in a controlled order: names on the scope (slot types, loop variables, `stm-def-*` locals) win; names missing from the scope but present as genuine JavaScript globals (`Math`, `parseInt`, `String`, …) forward to that global; anything else resolves to `undefined` rather than throwing a `ReferenceError` or leaking an unintended global. An expression that mentions a slot the client does not hold therefore degrades gracefully — `unsetSlot == null` evaluates to `true` — and property access on an unset slot's `undefined` yields `undefined` for that expression alone.
+
 ## URL Overrides
 
 The attribute `stm-entrypoint` can be used *only* on the `<body>` tag, to override the default entrypoint url of `/_statum/entrypoint`. Same goes for the `stm-slots` attribute (default slot post url `/_statum/slots`), the `stm-channel` attribute (default realtime channel url `/_statum/channel`), and the `stm-worker` attribute (default `SharedWorker` script url `/_statum/worker.js`).
@@ -328,7 +330,9 @@ The attribute `stm-attribute.{name}` (where `{name}` is any attribute name) bind
 
 ## Conditional display
 
-The attribute `stm-if` adds or removes the element from the DOM depending on the expression inside the statement, e.g. `<div stm-if="typeName.visable">` or `<div stm-if="typeName.level > 5">`
+The attribute `stm-if` shows or hides the element via the `hidden` attribute depending on the expression inside the statement, e.g. `<div stm-if="typeName.visable">` or `<div stm-if="typeName.level > 5">`
+
+Hidden branches stay in the DOM (they are re-evaluated on each render), so the `hidden` attribute must win over author CSS. The library injects `[hidden]{display:none !important}` into the document head at initialisation for exactly this reason — an ordinary rule like `li { display: flex }` would otherwise override the user-agent `[hidden]` styling and let hidden elements leak as phantom rows.
 
 The attribute `stm-else-if` can only exist as the next sibling of an element with either `stm-if` or `stm-else-if` and has the same type of value as `stm-if`.
 

+ 2 - 1
src/ActionEndpoint.vala

@@ -16,7 +16,8 @@ namespace Statum {
      * StatumAction.handle}, render directives). Registered for all common verbs
      * so an action may be authored with whichever HTTP method suits it.
      *
-     * This mirrors Spry's single `ComponentEndpoint`/`PathProvider` model.
+     * This mirrored Spry 0.1's single `ComponentEndpoint`/`PathProvider`
+     * model; both classes were removed in Spry 0.2.
      */
     public class ActionEndpoint : Object, Endpoint {
 

+ 2 - 2
src/ActionRegistry.vala

@@ -5,8 +5,8 @@ using Inversion;
 namespace Statum {
 
     /**
-     * Resolves Statum actions to/from their auto-assigned GUID endpoints, the
-     * Statum analogue of Spry's {@link Spry.PathProvider}.
+     * Resolves Statum actions to/from their auto-assigned GUID endpoints; the
+     * design follows the old Spry 0.1 `PathProvider`, removed in Spry 0.2.
      *
      * Every action registered via {@link StatumConfigurator.action} is assigned a
      * random GUID and served at `/_statum/action/{guid}` by a single

+ 2 - 1
src/ChannelSubscriptionEndpoint.vala

@@ -32,7 +32,8 @@ namespace Statum {
             Enumerable<string> unsubscribe_keys = Iterate.nothing<string>();
 
             if (body_str.strip().length > 0) {
-                var root = new JsonElement.from_string(body_str).as<JsonObject>();
+                var body_element = new JsonElement.from_string(body_str);
+                var root = body_element.as<JsonObject>();
                 subscribe_keys = read_string_array(root, "subscribe");
                 unsubscribe_keys = read_string_array(root, "unsubscribe");
             }

+ 13 - 1
src/Cryptography/EncryptionProvider.vala

@@ -1,4 +1,5 @@
 using Invercargill;
+using Invercargill.DataStructures;
 using InvercargillJson;
 
 namespace Statum.Cryptography {
@@ -97,6 +98,11 @@ namespace Statum.Cryptography {
          * Decrypts a base64 blob, verifies it, and re-parses the JSON back into
          * a {@link Properties} object.
          *
+         * Values are re-parsed into fresh {@link JsonElement}s owned by the
+         * returned dictionary — the parsed tree behind a temporary
+         * {@link JsonElement} is freed with it, so a wrapper extracted from one
+         * must not outlive the statement that created it.
+         *
          * @param namespace The namespace the blob is expected to carry.
          * @param blob The base64-encoded encrypted blob.
          * @return The decrypted structured private data.
@@ -105,7 +111,13 @@ namespace Statum.Cryptography {
          */
         public Properties read_properties(string namespace, string blob) throws GLib.Error {
             var json = read_string(namespace, blob);
-            return new JsonElement.from_string(json).as<Properties>();
+            var element = new JsonElement.from_string(json);
+            var parsed = element.as<JsonObject>();
+            var props = new PropertyDictionary();
+            foreach (var item in parsed) {
+                props[item.key] = new JsonElement.from_string(item.value.stringify());
+            }
+            return props;
         }
 
         /**

+ 8 - 1
src/DirectiveBuilder.vala

@@ -69,6 +69,12 @@ namespace Statum {
          * `TPublic` (action-ref fields included), applies `mutator`, maps back
          * and {@link update}s the slot (sign + auto-push). Emits an `error`
          * directive if the slot is not held or not cached.
+         *
+         * Safe with list-bearing models: `string[]` fields round-trip through
+         * the mapping, and JSON-array fields the typed model cannot read back
+         * (collection-typed properties — see
+         * {@link GObjectMapping.to_properties}) are carried forward unchanged
+         * rather than dropped.
          */
         public DirectiveBuilder update_held<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
             HeldSlot held_slot;
@@ -93,7 +99,8 @@ namespace Statum {
 
             var new_state = new State() {
                 type_name = current.type_name,
-                public_data = GObjectMapping.to_properties((Object) model),
+                public_data = GObjectMapping.preserving_arrays(current.public_data ?? new PropertyDictionary(),
+                    GObjectMapping.to_properties((Object) model)),
                 private_data = current.private_data ?? new PropertyDictionary()
             };
             items.add(new Item() { kind = ItemKind.UPDATE, key = key, update_state = new_state });

+ 186 - 9
src/GObjectMapping.vala

@@ -21,11 +21,16 @@ namespace Statum {
      * `public string order_id { get; set; }`) and have the framework map it
      * with no per-model mapper.
      *
-     * Primitives are handled via {@link Element.to_value}/{@link ValueElement}.
+     * Scalar properties (`string`, `int`, `int64`, `long`, `bool`, `double`,
+     * enums) serialise as type-correct JSON primitives on every Statum path —
+     * the public snapshot path and the encrypted private path
+     * ({@link Statum.Cryptography.EncryptionProvider.author_properties}) alike.
      * `Object`-typed properties whose {@link Type} is registered (see
      * {@link register_mapper}) are mapped recursively via their
      * {@link PropertyMapper} — this is how typed action-reference fields (e.g.
      * `public Model.ActionDto bump { get; set; }`) round-trip as nested JSON.
+     * Array-shaped properties serialise as JSON arrays; see {@link to_properties}
+     * for the supported shapes and {@link from_properties} for read-back.
      */
     public class GObjectMapping : Object {
 
@@ -49,12 +54,34 @@ namespace Statum {
         }
 
         /**
-         * Maps a GObject instance to a {@link Properties} object by reading its
-         * declared (readable) GObject properties. Registered `Object`-typed
-         * properties are mapped recursively; unregistered `Object` properties are
-         * skipped.
+         * Maps a GObject instance to a {@link PropertyDictionary} by reading its
+         * declared (readable) GObject properties, ready to be used directly as
+         * slot public/private data (no cast required — see S-13).
+         *
+         * Scalars (`string`, `int`, `int64`, `long`, `bool`, `double`, enums)
+         * serialise as JSON primitives of the matching type on every path,
+         * {@link Statum.Cryptography.EncryptionProvider.author_properties}
+         * included. Registered `Object`-typed properties map recursively as
+         * nested JSON objects; unregistered `Object` properties are skipped
+         * unless they hold a collection (below).
+         *
+         * Arrays serialise as JSON arrays for two shapes: `string[]`
+         * properties, and `Object`-typed properties whose runtime value is an
+         * Invercargill {@link Invercargill.Enumerable} (e.g. `Series<T>`,
+         * `Lot<T>`, `Vector<T>`). Collection elements that are registered
+         * {@link Object} types map recursively via their mapper; fundamental
+         * elements (`string`, `int`, `int64`, `bool`, `double`) map as JSON
+         * primitives.
+         *
+         * **Limitation:** collection-typed properties are write-side only — a
+         * property's GType does not record the collection's element type, so
+         * {@link from_properties} cannot repopulate them. Models carrying
+         * collection fields must be rebuilt from their source of truth (e.g.
+         * the database) rather than round-tripped;
+         * {@link DirectiveBuilder.update_held} carries such fields forward
+         * unchanged instead of dropping them.
          */
-        public static Properties to_properties(Object model) throws GLib.Error {
+        public static PropertyDictionary to_properties(Object model) throws GLib.Error {
             var dict = new PropertyDictionary();
             var object_class = (ObjectClass) model.get_type().class_ref();
             foreach (var spec in object_class.list_properties()) {
@@ -78,7 +105,21 @@ namespace Statum {
                 }
 
                 if (spec.value_type.is_a(typeof(Object))) {
-                    // Unregistered Object property: skip (would not serialise).
+                    var obj = value.get_object();
+                    if (obj != null && ((!)obj).get_type().is_a(typeof(Enumerable))) {
+                        dict[key] = enumerable_to_array_element((!)obj);
+                    }
+                    // Other unregistered Object properties do not serialise.
+                    continue;
+                }
+
+                if (spec.value_type == typeof(string[])) {
+                    var raw = value.get_boxed();
+                    if (raw != null) {
+                        dict[key] = strv_to_array_element(raw);
+                    } else {
+                        dict[key] = new ValueElement(value);
+                    }
                     continue;
                 }
 
@@ -90,7 +131,14 @@ namespace Statum {
         /**
          * Builds a new instance of `type` and populates its (writable) GObject
          * properties from `props`. Registered `Object`-typed properties are
-         * materialised recursively.
+         * materialised recursively; `string[]` properties are populated from
+         * JSON arrays; JSON numbers/booleans coerce back into `int`, `int64`,
+         * `long`, `double`, enum and boolean properties (including `long` and
+         * `ulong`, which {@link Element.to_value} cannot express on its own).
+         *
+         * Collection-typed properties are not repopulated — see the limitation
+         * documented on {@link to_properties} for the rebuild-from-source
+         * pattern that replaces round-tripping such models.
          */
         public static Object from_properties(Type type, Properties props) throws GLib.Error {
             var model = Object.new(type);
@@ -116,10 +164,25 @@ namespace Statum {
                     continue;
                 }
 
+                if (spec.value_type == typeof(string[])) {
+                    try {
+                        var value = Value(spec.value_type);
+                        value.set_boxed(read_string_array((!)element));
+                        model.set_property(spec.name, value);
+                    } catch (GLib.Error e) {
+                        // Skip properties whose array cannot be read.
+                    }
+                    continue;
+                }
+
                 try {
-                    var value = ((!)element).to_value(spec.value_type);
+                    var value = element_value(((!)element), spec.value_type);
                     model.set_property(spec.name, value);
                 } catch (GLib.Error e) {
+                    var coerced = coerce_numeric((!)element, spec.value_type);
+                    if (coerced != null) {
+                        model.set_property(spec.name, (!)coerced);
+                    }
                     // Skip properties whose value cannot be coerced.
                 }
             }
@@ -133,6 +196,120 @@ namespace Statum {
             return (T) from_properties(type, props);
         }
 
+        /**
+         * Returns `mapped` with the JSON-array values of `current` that
+         * `mapped` no longer carries re-attached unchanged. A typed update
+         * cannot re-emit collection fields it was unable to read back into the
+         * model, so this keeps them instead of silently dropping them.
+         */
+        internal static PropertyDictionary preserving_arrays(Properties current, PropertyDictionary mapped) {
+            foreach (var item in current) {
+                Element existing;
+                if (mapped.try_get(item.key, out existing)) {
+                    continue;
+                }
+                if (item.value != null && item.value.assignable_to<Elements>()) {
+                    mapped[item.key] = item.value;
+                }
+            }
+            return mapped;
+        }
+
+        /**
+         * Builds the JSON array {@link Element} for a `string[]` (`GStrv`)
+         * property value.
+         */
+        private static Element strv_to_array_element(void* strv) {
+            var items = new Series<Element>();
+            var cursor = (string**) strv;
+            for (var i = 0; cursor[i] != null; i++) {
+                var value = Value(typeof(string));
+                value.set_string((string) cursor[i]);
+                items.add(new ValueElement(value));
+            }
+            return new NativeElement<Elements>(items.to_elements());
+        }
+
+        /**
+         * Builds the JSON array {@link Element} for an Invercargill collection
+         * property value: registered `Object` elements map recursively via
+         * their mapper, fundamental elements pass through as primitives.
+         */
+        private static Element enumerable_to_array_element(Object enumerable) throws GLib.Error {
+            var boxed = Value(typeof(Enumerable));
+            boxed.set_object(enumerable);
+            var items = new Series<Element>();
+            foreach (var item in new ValueElement(boxed).as<Elements>()) {
+                var item_type = item.type();
+                if (item_type != null && mappers().lookup((!)item_type) != null) {
+                    var obj = item.as<Object>();
+                    if (obj != null) {
+                        var sub_props = (!)(mappers().lookup((!)item_type)).map_from_object((!)obj);
+                        items.add(new JsonElement.from_properties(sub_props));
+                        continue;
+                    }
+                }
+                items.add(item);
+            }
+            return new NativeElement<Elements>(items.to_elements());
+        }
+
+        /**
+         * Reads a JSON array {@link Element} back into a null-terminated
+         * `string[]` suitable for a `GStrv` property value.
+         */
+        private static string[] read_string_array(Element element) throws GLib.Error {
+            string[] items = new string[0];
+            foreach (var item in element.as<Elements>()) {
+                items += item.as_string_or_null() ?? "";
+            }
+            return items;
+        }
+
+        /**
+         * Coerces a JSON number into the numeric targets
+         * {@link Element.to_value} cannot produce on its own (`long`, `ulong`),
+         * or returns `null` when the target is not coercible.
+         */
+        private static Value? coerce_numeric(Element element, Type target) {
+            try {
+                if (target == typeof(long)) {
+                    var value = Value(target);
+                    value.set_long((long) element.as<int64?>());
+                    return value;
+                }
+                if (target == typeof(ulong)) {
+                    var value = Value(target);
+                    value.set_ulong((ulong) element.as<int64?>());
+                    return value;
+                }
+            } catch (GLib.Error e) {
+            }
+            return null;
+        }
+
+        /**
+         * Converts `element` to a `Value` of `target`: a {@link ValueElement}
+         * holding a transformable fundamental is converted through native
+         * GValue transformation (its raw scalar never passes through the
+         * generic element dispatch, whose int64/uint64 branches mis-handle
+         * the boxed representation); everything else goes through
+         * {@link Element.to_value}.
+         */
+        private static Value element_value(Element element, Type target) throws GLib.Error {
+            var value_element = element as ValueElement;
+            if (value_element != null) {
+                var raw = ((!)value_element).get_value();
+                if (Value.type_transformable(raw.type(), target)) {
+                    var converted = Value(target);
+                    if (raw.transform(ref converted)) {
+                        return converted;
+                    }
+                }
+            }
+            return element.to_value(target);
+        }
+
         // ------------------------------------------------------------------
         // Typed {@link Properties} getters — remove ad-hoc read boilerplate.
         // ------------------------------------------------------------------

+ 2 - 1
src/ResourceEndpoint.vala

@@ -8,7 +8,8 @@ namespace Statum {
     /**
      * `GET /_statum/resource/{name}` — serves precompressed {@link StatumResource}s.
      *
-     * Mirrors Spry's {@link Spry.StaticResourceProvider}: all registered
+     * Mirrors the old Spry 0.1 `StaticResourceProvider` (removed in Spry
+     * 0.2): all registered
      * {@link StatumResource}s are collected at construction, the `{name}` segment
      * selects one, the best encoding is chosen from `Accept-Encoding`, and
      * `ETag`/`If-None-Match` is honoured.

+ 4 - 2
src/SlotPostEndpoint.vala

@@ -31,7 +31,8 @@ namespace Statum {
             var invalid_signatures = new Series<string>();
 
             if (body_str.strip().length > 0) {
-                var array = new JsonElement.from_string(body_str).as<JsonArray>();
+                var body_element = new JsonElement.from_string(body_str);
+                var array = body_element.as<JsonArray>();
                 foreach (var item in array) {
                     var frame = Model.FrameDto.get_mapper().materialise(item.as<JsonObject>());
 
@@ -46,7 +47,8 @@ namespace Statum {
                         continue;
                     }
 
-                    var content_object = new JsonElement.from_string(frame.content).as<JsonObject>();
+                    var content_element = new JsonElement.from_string(frame.content);
+                    var content_object = content_element.as<JsonObject>();
                     var snapshot = Model.SnapshotDto.get_mapper().materialise(content_object);
 
                     state_service.restore_slot(frame);

+ 8 - 2
src/StateService.vala

@@ -140,6 +140,10 @@ namespace Statum {
          * Typed read-mutate-write of a global slot: maps its current public data
          * to `TPublic`, applies `mutator`, maps back, signs and pushes. Used from
          * background contexts (timers, webhooks) that have no request scope.
+         *
+         * Like {@link DirectiveBuilder.update_held}, JSON-array fields the typed
+         * model cannot read back are carried forward unchanged rather than
+         * dropped.
          */
         public async void update_global_typed<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
             var key = GLOBAL_KEY_PREFIX + type_name;
@@ -153,7 +157,8 @@ namespace Statum {
             mutator(model);
             var new_state = new State() {
                 type_name = current.type_name,
-                public_data = GObjectMapping.to_properties((Object) model),
+                public_data = GObjectMapping.preserving_arrays(current.public_data ?? new PropertyDictionary(),
+                    GObjectMapping.to_properties((Object) model)),
                 private_data = current.private_data ?? new PropertyDictionary()
             };
             yield update(key, new_state);
@@ -166,7 +171,8 @@ namespace Statum {
 
         public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
             signing_provider.verify_frame(frame);
-            var json_object = new JsonElement.from_string(frame.content).as<JsonObject>();
+            var content_element = new JsonElement.from_string(frame.content);
+            var json_object = content_element.as<JsonObject>();
             var snapshot = Model.SnapshotDto.get_mapper().materialise(json_object);
 
             var slot = get_slot(snapshot.slot.key);

+ 16 - 2
src/Statum.vala

@@ -83,7 +83,14 @@ namespace Statum {
                     return new Cryptography.SigningProvider.with_keys(Base64.decode(sk), Base64.decode(pk));
                 }
             }
-            warning("[Statum] No static signing key configured; frames will not survive a restart.");
+            warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
+                + "[Statum]  No static signing key configured (web-config.json, \"statum\" section).\n"
+                + "[Statum]  Frames are signed with an EPHEMERAL key:\n"
+                + "[Statum]    → every client-held slot (including sessions) is INVALIDATED\n"
+                + "[Statum]      the moment this process restarts.\n"
+                + "[Statum]  Run `statum-genkeys` and add the generated keys to web-config.json\n"
+                + "[Statum]  to make frames survive restarts.\n"
+                + "[Statum] ────────────────────────────────────────────────────────────────────");
             return new Cryptography.SigningProvider();
         }
 
@@ -101,7 +108,14 @@ namespace Statum {
                         Base64.decode(esk), Base64.decode(epk));
                 }
             }
-            warning("[Statum] No static encryption keys configured; private blobs will not survive a restart.");
+            warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
+                + "[Statum]  No static encryption keys configured (web-config.json, \"statum\" section).\n"
+                + "[Statum]  Private blobs are sealed with EPHEMERAL keys:\n"
+                + "[Statum]    → action/snapshot private data becomes unreadable\n"
+                + "[Statum]      the moment this process restarts.\n"
+                + "[Statum]  Run `statum-genkeys` and add the generated keys to web-config.json\n"
+                + "[Statum]  to make private blobs survive restarts.\n"
+                + "[Statum] ────────────────────────────────────────────────────────────────────");
             return new Cryptography.EncryptionProvider();
         }
 

+ 42 - 6
src/StatumHandlers.vala

@@ -159,6 +159,8 @@ namespace Statum {
                 return encryption_provider.read_properties(private_namespace, (!)blob);
             } catch (GLib.Error e) {
                 failed = true;
+                warning("[Statum] Could not decrypt the X-Statum-Private header under namespace \"%s\" (action %s): %s",
+                    private_namespace, this.get_type().name(), e.message);
                 return new PropertyDictionary();
             }
         }
@@ -184,21 +186,55 @@ namespace Statum {
         /** Typed actions require valid `X-Statum-Private`; bad/absent → error directive. */
         protected override bool requires_valid_private { get { return true; } }
 
-        private TPrivate? _private_data = null;
+        /**
+         * The {@link Type} materialised by {@link request_private}.
+         *
+         * The default returns `typeof(TPrivate)`, which erodes to `void` when
+         * the action is instantiated reflectively — the usual case, because
+         * registered actions are resolved by GType without their generic type
+         * argument. Such subclasses must override this to name their private
+         * model directly:
+         *
+         * ```
+         * protected override Type private_type { get { return typeof(MyPrivate); } }
+         * ```
+         */
+        protected virtual Type private_type { get { return typeof(TPrivate); } }
+
+        private Object? _private_data = null;
         private bool _private_resolved = false;
 
-        /** The decrypted, typed private data for this action, or `null`. */
+        /**
+         * The decrypted, typed private data for this action, or `null` when the
+         * blob is absent, undecryptable, or the action's {@link private_type}
+         * is not a {@link Object} type (see the erasure note there).
+         *
+         * The materialised model is held in a plain `Object` field so the
+         * reference survives the erased-generic return path (a container-
+         * constructed action carries no `TPrivate` dup function, so a direct
+         * `(TPrivate)` transfer would drop the only reference).
+         */
         protected TPrivate? request_private {
             get {
                 if (!_private_resolved) {
                     _private_resolved = true;
-                    try {
-                        _private_data = (TPrivate) GObjectMapping.from_properties(typeof(TPrivate), request.action_private);
-                    } catch (GLib.Error e) {
+                    var type = private_type;
+                    if (!type.is_a(typeof(Object))) {
+                        warning("[Statum] %s: typeof(TPrivate) resolved to \"%s\" at runtime (generic type erasure); "
+                            + "override `private_type` to return the private model's type so request_private can materialise it.",
+                            this.get_type().name(), type.name());
                         _private_data = null;
+                    } else {
+                        try {
+                            _private_data = GObjectMapping.from_properties(type, request.action_private);
+                        } catch (GLib.Error e) {
+                            warning("[Statum] %s: could not materialise typed private data from the decrypted blob: %s",
+                                this.get_type().name(), e.message);
+                            _private_data = null;
+                        }
                     }
                 }
-                return _private_data;
+                return (TPrivate) _private_data;
             }
         }
 

+ 2 - 1
src/StatumResource.vala

@@ -8,7 +8,8 @@ namespace Statum {
      * A precompressed, named resource served from the Statum resource endpoint
      * (`/_statum/resource/{name}`).
      *
-     * Mirrors Spry's {@link Spry.StaticResource}: each concrete resource exposes
+     * Mirrors the old Spry 0.1 `StaticResource` (removed in Spry 0.2): each
+     * concrete resource exposes
      * the encodings it was precompressed with (identity/gzip/zstd/br), an ETag
      * per encoding, and the ability to render itself as an {@link HttpResult}.
      */

+ 328 - 0
statum-issues.md

@@ -0,0 +1,328 @@
+# 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 == null` → `undefined == null` → `true`. 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 `JsonElement`s.)
+
+### 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)` `ReferenceError`s.
+
+### 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` `ValueElement`s
+> **FIXED (2026-08-31, WP1 server-side effort).** `GObjectMapping.from_properties`
+> now converts cached `ValueElement`s 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 `ValueElement`s (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 `JsonElement`s. (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.

+ 217 - 0
tests/TestMain.vala

@@ -0,0 +1,217 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Mapping;
+using InvercargillJson;
+using Inversion;
+using Statum;
+
+namespace Statum.Tests {
+
+    int failures = 0;
+    int passes = 0;
+
+    void check(bool condition, string label) {
+        if (condition) {
+            passes++;
+            print("PASS %s\n", label);
+        } else {
+            failures++;
+            print("FAIL %s\n", label);
+        }
+    }
+
+    /**
+     * Scalar model in the style of Percipio's login private data: the field
+     * types that previously crashed (int64, bool) or mis-serialised (int) the
+     * encryption path.
+     */
+    public class LoginPrivate : Object {
+        public int64 user_id { get; set; }
+        public bool is_admin { get; set; }
+        public int attempts { get; set; }
+        public double score { get; set; }
+        public string username { get; set; }
+        public long session_no { get; set; }
+    }
+
+    /** List-bearing model: a `string[]` plus a collection of a registered type. */
+    public class ArrayModel : Object {
+        public string[] tags { get; set; }
+        public Series<Model.ActionDto> refs { get; set; }
+    }
+
+    /** Typed action WITHOUT a `private_type` override — demonstrates S-8 erosion. */
+    public class ErodingAction : TypedStatumAction<LoginPrivate> {
+        public Type exposed_private_type() {
+            return private_type;
+        }
+
+        public LoginPrivate? exposed_request_private() {
+            return request_private;
+        }
+
+        public void feed(Properties props) {
+            request = new StatumRequest() { action_private = props };
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            return directives();
+        }
+    }
+
+    /** Typed action WITH the `private_type` override — the supported pattern. */
+    public class FixedAction : TypedStatumAction<LoginPrivate> {
+        protected override Type private_type { get { return typeof(LoginPrivate); } }
+
+        public Type exposed_private_type() {
+            return private_type;
+        }
+
+        public LoginPrivate? exposed_request_private() {
+            return request_private;
+        }
+
+        public void feed(Properties props) {
+            request = new StatumRequest() { action_private = props };
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            return directives();
+        }
+    }
+
+    LoginPrivate sample_private() {
+        return new LoginPrivate() {
+            user_id = (int64)9007199254740993,
+            is_admin = true,
+            attempts = 3,
+            score = 0.5,
+            username = "bbarrow",
+            session_no = (long)123456789012345
+        };
+    }
+
+    void assert_private_matches(LoginPrivate expected, LoginPrivate actual, string label) {
+        check(actual.user_id == expected.user_id, label + ": user_id int64 round-trip");
+        check(actual.is_admin == expected.is_admin, label + ": is_admin bool round-trip");
+        check(actual.attempts == expected.attempts, label + ": attempts int round-trip");
+        check(actual.score == expected.score, label + ": score double round-trip");
+        check(actual.username == expected.username, label + ": username string round-trip");
+        check(actual.session_no == expected.session_no, label + ": session_no long round-trip");
+    }
+
+    void test_scalar_public_path() throws GLib.Error {
+        var model = sample_private();
+        var props = GObjectMapping.to_properties(model);
+
+        var wire = new JsonElement.from_properties(props).stringify();
+        check(wire.contains("\"user_id\":9007199254740993"), "public path: int64 serialises as an unquoted JSON number");
+        check(wire.contains("\"is_admin\":true"), "public path: bool serialises as an unquoted JSON boolean");
+        check(wire.contains("\"attempts\":3"), "public path: int serialises as an unquoted JSON number, not a string");
+        check(wire.contains("\"score\":0.5"), "public path: double serialises as an unquoted JSON number");
+
+        var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate),
+            new JsonElement.from_string(wire).as<Properties>());
+        assert_private_matches(model, back, "public path");
+    }
+
+    void test_scalar_encryption_path(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
+        var model = sample_private();
+        var blob = encryption.author_properties("statum-tests", GObjectMapping.to_properties(model));
+        var props = encryption.read_properties("statum-tests", blob);
+        var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate), props);
+        assert_private_matches(model, back, "encryption path");
+    }
+
+    void test_arrays(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
+        var first = new Model.ActionDto() { uri = "/_statum/action/aaaa", method = "POST" };
+        var second = new Model.ActionDto() { uri = "/_statum/action/bbbb", method = "DELETE" };
+        var refs = new Series<Model.ActionDto>();
+        refs.add(first);
+        refs.add(second);
+        var model = new ArrayModel() { tags = new string[] { "alpha", "beta", "gamma" }, refs = refs };
+
+        var props = GObjectMapping.to_properties(model);
+        var wire = new JsonElement.from_properties(props).stringify();
+        check(wire.contains("\"tags\":[\"alpha\",\"beta\",\"gamma\"]"), "arrays: string[] serialises as a JSON array");
+        check(wire.contains("\"uri\":\"/_statum/action/bbbb\"")
+            && wire.contains("\"method\":\"DELETE\""), "arrays: registered-Object collection serialises as an array of nested objects");
+
+        var parsed = new JsonElement.from_string(wire).as<Properties>();
+        var array_model = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel), parsed);
+        check(array_model.tags.length == 3 && array_model.tags[0] == "alpha"
+            && array_model.tags[1] == "beta" && array_model.tags[2] == "gamma", "arrays: string[] read-back from JSON array");
+
+        var blob = encryption.author_properties("statum-tests", props);
+        var decrypted = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel),
+            encryption.read_properties("statum-tests", blob));
+        check(decrypted.tags.length == 3 && decrypted.tags[2] == "gamma", "arrays: string[] round-trips through the encryption path");
+    }
+
+    void test_typed_action_private(Inversion.Scope scope, Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
+        var eroding = (ErodingAction) scope.resolve_type(typeof(ErodingAction));
+        check(eroding.exposed_private_type() == typeof(void), "S-8 repro: typeof(TPrivate) erodes to void for a reflectively-instantiated action");
+        eroding.feed(new PropertyDictionary());
+        check(eroding.exposed_request_private() == null, "S-8 repro: un-overridden request_private yields null without crashing");
+
+        var fixed_action = (FixedAction) scope.resolve_type(typeof(FixedAction));
+        check(fixed_action.exposed_private_type() == typeof(LoginPrivate), "S-8 fix: private_type override captures the GType from a foreign namespace");
+
+        var model = sample_private();
+        var blob = encryption.author_properties(fixed_action.get_type().name(), GObjectMapping.to_properties(model));
+        fixed_action.feed(encryption.read_properties(fixed_action.get_type().name(), blob));
+        var private_data = fixed_action.exposed_request_private();
+        check(private_data != null, "S-8 fix: request_private materialises from a sealed blob");
+        if (private_data != null) {
+            assert_private_matches(model, (!)private_data, "S-8 fix");
+        }
+    }
+
+    void main() {
+        GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
+
+        var container = new Container();
+        container.register_singleton<Statum.Cryptography.SigningProvider>();
+        container.register_singleton<Statum.Cryptography.EncryptionProvider>();
+        container.register_singleton<StateService>();
+        container.register_singleton<HeldSlotResolver>();
+        container.register_singleton<ChannelEndpoint>().as<ChannelService>();
+        container.register_singleton<ActionRegistry>();
+        container.register_singleton<TopicRegistry>();
+        container.register_scoped<ErodingAction>();
+        container.register_scoped<FixedAction>();
+        var scope = container.create_transient_scope();
+
+        var encryption = (Statum.Cryptography.EncryptionProvider) scope.resolve_type(typeof(Statum.Cryptography.EncryptionProvider));
+
+        try {
+            test_scalar_public_path();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL scalar public path threw: %s\n", e.message);
+        }
+        try {
+            test_scalar_encryption_path(encryption);
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL scalar encryption path threw: %s\n", e.message);
+        }
+        try {
+            test_arrays(encryption);
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL arrays threw: %s\n", e.message);
+        }
+        try {
+            test_typed_action_private(scope, encryption);
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL typed action private threw: %s\n", e.message);
+        }
+
+        print("---- %d passed, %d failed ----\n", passes, failures);
+        if (failures > 0) {
+            Process.exit(1);
+        }
+    }
+}

+ 11 - 0
tests/meson.build

@@ -0,0 +1,11 @@
+# Statum test suite: scalar/list serialization round-trips (public + encrypted
+# paths) and the cross-namespace typed-action private-data reproduction (S-8).
+# Prints PASS/FAIL lines and exits non-zero on failure.
+
+statum_tests = executable('statum-tests',
+    ['TestMain.vala'],
+    dependencies: [statum_dep],
+    install: false
+)
+
+test('statum', statum_tests)

+ 1 - 2
tools/statum-mkres/statum-mkres.vala

@@ -8,8 +8,7 @@ namespace Statum.Tools {
      * `statum-mkres` — generates a {@link Statum.ConstantStatumResource} Vala
      * subclass from an input file, precompressing it with identity/gzip/zstd/br.
      *
-     * Mirrors `spry-mkssr --vala`. Intended to be wired as a meson
-     * `custom_target` (see `tools/meson.build`).
+     * Intended to be wired as a meson `custom_target` (see `tools/meson.build`).
      *
      *     statum-mkres -o LogoResource.vala -n logo.png logo.png
      *     statum-mkres --ns MyApp.Static -c text/css -o Styles.vala styles.css