Просмотр исходного кода

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

clanker 1 неделя назад
Родитель
Сommit
4e4f108eab

+ 7 - 0
example/Actions.vala

@@ -56,9 +56,16 @@ namespace Example {
      * `POST /_statum/action/{guid}` — bumps the counter by the typed private
      * `POST /_statum/action/{guid}` — bumps the counter by the typed private
      * `delta`, using the typed {@link DirectiveBuilder.update_held} so the
      * `delta`, using the typed {@link DirectiveBuilder.update_held} so the
      * `bump` action reference is preserved automatically across the update.
      * `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> {
     public class BumpCounterAction : TypedStatumAction<CounterPrivate> {
 
 
+        protected override Type private_type { get { return typeof(CounterPrivate); } }
+
         public override async DirectiveBuilder handle() throws GLib.Error {
         public override async DirectiveBuilder handle() throws GLib.Error {
             var delta = request_private != null ? ((!)request_private).delta : 1;
             var delta = request_private != null ? ((!)request_private).delta : 1;
             return directives().update_held<CounterPublic>("counter", c => c.value += delta);
             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 {
         public override async DirectiveBuilder handle() throws GLib.Error {
             var bump_private = new CounterPrivate();
             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 bump_ref = action_registry.author_private<BumpCounterAction, CounterPrivate>(bump_private);
 
 
             var counter = new CounterPublic();
             var counter = new CounterPublic();

+ 1 - 1
example/counter-card.html

@@ -8,6 +8,6 @@
 </head>
 </head>
 <body pstm-materialise-as="section" class="counter-card">
 <body pstm-materialise-as="section" class="counter-card">
     <p>Counter: <span stm-text="counter.value">0</span></p>
     <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>
 </body>
 </html>
 </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
 namespace. For action private data, the namespace is the action type name (so
 one action cannot decrypt another's private blob).
 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
 ## Entrypoint Handlers
@@ -512,7 +536,9 @@ Started via `directives()` on any handler. Fluent, self-returning:
 ### `GObjectMapping`
 ### `GObjectMapping`
 
 
 Maps GObject models to/from `Properties` via introspection (no per-model
 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 |
 | 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();
   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`.
    * Compile (and cache) an expression string into a function of `scope`.
    * Uses `with` so that arbitrary type names and loop variables are resolved
    * Uses `with` so that arbitrary type names and loop variables are resolved
@@ -153,8 +177,10 @@
     var fn;
     var fn;
     try {
     try {
       // Function-constructor bodies are non-strict by default, so `with` is
       // 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;}}');
       fn = new Function('scope', 'with(scope){try{return (' + expr + ');}catch(e){return undefined;}}');
     } catch (e) {
     } catch (e) {
       fn = function () { return undefined; };
       fn = function () { return undefined; };
@@ -172,7 +198,7 @@
   function evalExpr(expr, scope) {
   function evalExpr(expr, scope) {
     if (expr == null || expr === '') return undefined;
     if (expr == null || expr === '') return undefined;
     try {
     try {
-      return compileExpr(expr)(scope || {});
+      return compileExpr(expr)(evalScope(scope || {}));
     } catch (e) {
     } catch (e) {
       return undefined;
       return undefined;
     }
     }
@@ -1750,6 +1776,25 @@
   // Initialization
   // 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
    * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
    * are intended for server-side pre-rendering and should never reach the
    * are intended for server-side pre-rendering and should never reach the
@@ -1785,6 +1830,10 @@
     if (initialized) return;
     if (initialized) return;
     initialized = true;
     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;
     var body = document.body;
     if (body) {
     if (body) {
       if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint');
       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('tools')
 subdir('src')
 subdir('src')
 subdir('example')
 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.
 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
 ## 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`).
 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
 ## 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`.
 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
      * StatumAction.handle}, render directives). Registered for all common verbs
      * so an action may be authored with whichever HTTP method suits it.
      * 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 {
     public class ActionEndpoint : Object, Endpoint {
 
 

+ 2 - 2
src/ActionRegistry.vala

@@ -5,8 +5,8 @@ using Inversion;
 namespace Statum {
 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
      * Every action registered via {@link StatumConfigurator.action} is assigned a
      * random GUID and served at `/_statum/action/{guid}` by a single
      * 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>();
             Enumerable<string> unsubscribe_keys = Iterate.nothing<string>();
 
 
             if (body_str.strip().length > 0) {
             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");
                 subscribe_keys = read_string_array(root, "subscribe");
                 unsubscribe_keys = read_string_array(root, "unsubscribe");
                 unsubscribe_keys = read_string_array(root, "unsubscribe");
             }
             }

+ 13 - 1
src/Cryptography/EncryptionProvider.vala

@@ -1,4 +1,5 @@
 using Invercargill;
 using Invercargill;
+using Invercargill.DataStructures;
 using InvercargillJson;
 using InvercargillJson;
 
 
 namespace Statum.Cryptography {
 namespace Statum.Cryptography {
@@ -97,6 +98,11 @@ namespace Statum.Cryptography {
          * Decrypts a base64 blob, verifies it, and re-parses the JSON back into
          * Decrypts a base64 blob, verifies it, and re-parses the JSON back into
          * a {@link Properties} object.
          * 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 namespace The namespace the blob is expected to carry.
          * @param blob The base64-encoded encrypted blob.
          * @param blob The base64-encoded encrypted blob.
          * @return The decrypted structured private data.
          * @return The decrypted structured private data.
@@ -105,7 +111,13 @@ namespace Statum.Cryptography {
          */
          */
         public Properties read_properties(string namespace, string blob) throws GLib.Error {
         public Properties read_properties(string namespace, string blob) throws GLib.Error {
             var json = read_string(namespace, blob);
             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
          * `TPublic` (action-ref fields included), applies `mutator`, maps back
          * and {@link update}s the slot (sign + auto-push). Emits an `error`
          * and {@link update}s the slot (sign + auto-push). Emits an `error`
          * directive if the slot is not held or not cached.
          * 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 {
         public DirectiveBuilder update_held<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
             HeldSlot held_slot;
             HeldSlot held_slot;
@@ -93,7 +99,8 @@ namespace Statum {
 
 
             var new_state = new State() {
             var new_state = new State() {
                 type_name = current.type_name,
                 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()
                 private_data = current.private_data ?? new PropertyDictionary()
             };
             };
             items.add(new Item() { kind = ItemKind.UPDATE, key = key, update_state = new_state });
             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
      * `public string order_id { get; set; }`) and have the framework map it
      * with no per-model mapper.
      * 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
      * `Object`-typed properties whose {@link Type} is registered (see
      * {@link register_mapper}) are mapped recursively via their
      * {@link register_mapper}) are mapped recursively via their
      * {@link PropertyMapper} — this is how typed action-reference fields (e.g.
      * {@link PropertyMapper} — this is how typed action-reference fields (e.g.
      * `public Model.ActionDto bump { get; set; }`) round-trip as nested JSON.
      * `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 {
     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 dict = new PropertyDictionary();
             var object_class = (ObjectClass) model.get_type().class_ref();
             var object_class = (ObjectClass) model.get_type().class_ref();
             foreach (var spec in object_class.list_properties()) {
             foreach (var spec in object_class.list_properties()) {
@@ -78,7 +105,21 @@ namespace Statum {
                 }
                 }
 
 
                 if (spec.value_type.is_a(typeof(Object))) {
                 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;
                     continue;
                 }
                 }
 
 
@@ -90,7 +131,14 @@ namespace Statum {
         /**
         /**
          * Builds a new instance of `type` and populates its (writable) GObject
          * Builds a new instance of `type` and populates its (writable) GObject
          * properties from `props`. Registered `Object`-typed properties are
          * 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 {
         public static Object from_properties(Type type, Properties props) throws GLib.Error {
             var model = Object.new(type);
             var model = Object.new(type);
@@ -116,10 +164,25 @@ namespace Statum {
                     continue;
                     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 {
                 try {
-                    var value = ((!)element).to_value(spec.value_type);
+                    var value = element_value(((!)element), spec.value_type);
                     model.set_property(spec.name, value);
                     model.set_property(spec.name, value);
                 } catch (GLib.Error e) {
                 } 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.
                     // Skip properties whose value cannot be coerced.
                 }
                 }
             }
             }
@@ -133,6 +196,120 @@ namespace Statum {
             return (T) from_properties(type, props);
             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.
         // 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.
      * `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
      * {@link StatumResource}s are collected at construction, the `{name}` segment
      * selects one, the best encoding is chosen from `Accept-Encoding`, and
      * selects one, the best encoding is chosen from `Accept-Encoding`, and
      * `ETag`/`If-None-Match` is honoured.
      * `ETag`/`If-None-Match` is honoured.

+ 4 - 2
src/SlotPostEndpoint.vala

@@ -31,7 +31,8 @@ namespace Statum {
             var invalid_signatures = new Series<string>();
             var invalid_signatures = new Series<string>();
 
 
             if (body_str.strip().length > 0) {
             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) {
                 foreach (var item in array) {
                     var frame = Model.FrameDto.get_mapper().materialise(item.as<JsonObject>());
                     var frame = Model.FrameDto.get_mapper().materialise(item.as<JsonObject>());
 
 
@@ -46,7 +47,8 @@ namespace Statum {
                         continue;
                         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);
                     var snapshot = Model.SnapshotDto.get_mapper().materialise(content_object);
 
 
                     state_service.restore_slot(frame);
                     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
          * Typed read-mutate-write of a global slot: maps its current public data
          * to `TPublic`, applies `mutator`, maps back, signs and pushes. Used from
          * to `TPublic`, applies `mutator`, maps back, signs and pushes. Used from
          * background contexts (timers, webhooks) that have no request scope.
          * 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 {
         public async void update_global_typed<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
             var key = GLOBAL_KEY_PREFIX + type_name;
             var key = GLOBAL_KEY_PREFIX + type_name;
@@ -153,7 +157,8 @@ namespace Statum {
             mutator(model);
             mutator(model);
             var new_state = new State() {
             var new_state = new State() {
                 type_name = current.type_name,
                 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()
                 private_data = current.private_data ?? new PropertyDictionary()
             };
             };
             yield update(key, new_state);
             yield update(key, new_state);
@@ -166,7 +171,8 @@ namespace Statum {
 
 
         public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
         public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
             signing_provider.verify_frame(frame);
             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 snapshot = Model.SnapshotDto.get_mapper().materialise(json_object);
 
 
             var slot = get_slot(snapshot.slot.key);
             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));
                     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();
             return new Cryptography.SigningProvider();
         }
         }
 
 
@@ -101,7 +108,14 @@ namespace Statum {
                         Base64.decode(esk), Base64.decode(epk));
                         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();
             return new Cryptography.EncryptionProvider();
         }
         }
 
 

+ 42 - 6
src/StatumHandlers.vala

@@ -159,6 +159,8 @@ namespace Statum {
                 return encryption_provider.read_properties(private_namespace, (!)blob);
                 return encryption_provider.read_properties(private_namespace, (!)blob);
             } catch (GLib.Error e) {
             } catch (GLib.Error e) {
                 failed = true;
                 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();
                 return new PropertyDictionary();
             }
             }
         }
         }
@@ -184,21 +186,55 @@ namespace Statum {
         /** Typed actions require valid `X-Statum-Private`; bad/absent → error directive. */
         /** Typed actions require valid `X-Statum-Private`; bad/absent → error directive. */
         protected override bool requires_valid_private { get { return true; } }
         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;
         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 {
         protected TPrivate? request_private {
             get {
             get {
                 if (!_private_resolved) {
                 if (!_private_resolved) {
                     _private_resolved = true;
                     _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;
                         _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
      * A precompressed, named resource served from the Statum resource endpoint
      * (`/_statum/resource/{name}`).
      * (`/_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
      * the encodings it was precompressed with (identity/gzip/zstd/br), an ETag
      * per encoding, and the ability to render itself as an {@link HttpResult}.
      * 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
      * `statum-mkres` — generates a {@link Statum.ConstantStatumResource} Vala
      * subclass from an input file, precompressing it with identity/gzip/zstd/br.
      * 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 -o LogoResource.vala -n logo.png logo.png
      *     statum-mkres --ns MyApp.Static -c text/css -o Styles.vala styles.css
      *     statum-mkres --ns MyApp.Static -c text/css -o Styles.vala styles.css