HTML files run through process at compile time, which processes the templates, creates variants, generates Vala code for predicates from Invercargill expressions. Compresses variants in all compression methods at max compression. Result is Vala files referenced in code.
Invercargill expressions for pstm-if with all possible pages pre-rendered and the correct one sent based on predicate matrix. Works with pstm-else and pstm-else-if
<pstm-guard> element in the <head> tag can cause an error page to be sent when a predicate isn't met.
Nested page templates (like spry templates, but precomputed) using a <pstm-template> element in the <head> tag.
Template/page/fragment name defined by <pstm-name> in head tag.
Reusable fragments precomputed into pages.
<pstm-uri> elements in head tag define (templated) routes for the page.
Fragment includable using <pstm-fragment>
<pstm-input> element in head tag that defines a variable that the fragment accepts as an input. Translates to a stm-def-x-as attribute when composited
<pstm-title> translates to a <title> tag but with extra attributes possible for composition, e.g. a template could define <pstm-title suffix> | My App</pstm-title> to append text to the child's title. prefix also supported.
<pstm-content> used in templates as the child outlet.
pstm-materialise-as attribute can be applied to a body tag of a fragment or template child to define what type of element the body tag should be converted to. Defaults to div
<pstm-fragment name="fragName"> embeds a fragment into the template. Any attributes matching a pstm-input of the target are translated to stm-def-x-as attributes
pstm-res-x attribute takes a resource name and translates it to a full resource URL e.g. /_statum/resource/xxx and assigns that to attribute x e.g. <img pstm-res-src="logo.webp" /> might translate to <img src="/_statum/resources/logo.webp" />
Add resource generator for meson (similar to HTML templates) to generate these resources.
Client side additions
New subscribe and unsubscribe directives.
New notify directive with type and message default handler triggers JavaScript alert
onErrorHandler, onConfirmHandler, and onNotifyHandler to JS API.
stm-def-x-as attribute allows defining a variable available to this element and child elements.
Detect any pstm-* tags and add a console warning.
Use GET /_statum/channel as an SSE source in a shared worker - channel ID sent in response header. Connection is established on first subscribe directive in the session. Connection is closed when all subscriptions have been unsubscribed.
Use PATCH /_statum/channel to manage the channel's subscriptions (to subscribe or unsubscribe from slots)
Perhaps use shared worker to hold session scoped slots instead of the cookie hack
Server side attributes
- `pstm-` prefix
- HTML files run through process at compile time, which processes the templates, creates variants, generates Vala code for predicates from Invercargill expressions. Compresses variants in all compression methods at max compression. Result is Vala files referenced in code.
- Invercargill expressions for `pstm-if` with all possible pages pre-rendered and the correct one sent based on predicate matrix. Works with `pstm-else` and `pstm-else-if`
- `<pstm-guard>` element in the `<head>` tag can cause an error page to be sent when a predicate isn't met.
- Nested page templates (like spry templates, but precomputed) using a `<pstm-template>` element in the `<head>` tag.
- Template/page/fragment name defined by `<pstm-name>` in head tag.
- Reusable fragments precomputed into pages.
- `<pstm-uri>` elements in head tag define (templated) routes for the page.
- Fragment includable using `<pstm-fragment>`
- `<pstm-input>` element in head tag that defines a variable that the fragment accepts as an input. Translates to a `stm-def-x-as` attribute when composited
- `<pstm-title>` translates to a `<title>` tag but with extra attributes possible for composition, e.g. a template could define `<pstm-title suffix> | My App</pstm-title>` to append text to the child's title. `prefix` also supported.
- `<pstm-content>` used in templates as the child outlet.
- `pstm-materialise-as` attribute can be applied to a `body` tag of a fragment or template child to define what type of element the `body` tag should be converted to. Defaults to `div`
- `<pstm-fragment name="fragName">` embeds a fragment into the template. Any attributes matching a `pstm-input` of the target are translated to `stm-def-x-as` attributes
- `pstm-res-x` attribute takes a resource name and translates it to a full resource URL e.g. `/_statum/resource/xxx` and assigns that to attribute `x` e.g. `<img pstm-res-src="logo.webp" />` might translate to `<img src="/_statum/resources/logo.webp" />`
- Add resource generator for meson (similar to HTML templates) to generate these resources.
Client side additions
- New `subscribe` and `unsubscribe` directives.
- New `notify` directive with `type` and `message` default handler triggers JavaScript `alert`
- `onErrorHandler`, `onConfirmHandler`, and `onNotifyHandler` to JS API.
- `stm-def-x-as` attribute allows defining a variable available to this element and child elements.
- Detect any `pstm-*` tags and add a console warning.
- Use `GET /_statum/channel` as an SSE source in a shared worker - channel ID sent in response header. Connection is established on first `subscribe` directive in the session. Connection is closed when all subscriptions have been unsubscribed.
- Use `PATCH /_statum/channel` to manage the channel's subscriptions (to subscribe or unsubscribe from slots)
- Perhaps use shared worker to hold `session` scoped slots instead of the cookie hack
stm-markdown attribute (like stm-html and stm-text) escapes all HTML so can be from untrusted input.
stm-h-demotion attribute that demotes all child header tags by a particular amount (must occurr after stm-html and stm-markdown)
form directive allowing submit (with optional action and URI) and reset of a form on the template by ID
Add before and after evaluation JS Api hooks
Automatic "set" directive sent by API if as_at date is before the last update of a state
Make HTML preprocessor usable as a meson generator instead of specifying command each time
- `stm-markdown` attribute (like stm-html and stm-text) escapes all HTML so can be from untrusted input.
- `stm-h-demotion` attribute that demotes all child header tags by a particular amount (must occurr after stm-html and stm-markdown)
- `form` directive allowing submit (with optional action and URI) and reset of a form on the template by ID
- Add before and after evaluation JS Api hooks
- Automatic "set" directive sent by API if `as_at` date is before the last update of a state
- Make HTML preprocessor usable as a meson generator instead of specifying command each time
stm-data-form attribute which references a form element to send data from along with the action
stm-selected which can set the selected value on a <select> element.
stm-open which specifies an element to "open" when clicked (e.g. a <dialog>) by ID. If the element specifying the stm-open attribute also has a stm-action then the open happens after the action successfully competes.
stm-close does the opposite to stm-open also completing only after any actions if present.
stm-dialog can be used only on <dialog> elements and can be set to either "confirm" or "alert". Statum will automatically override the default Statum alert and confirm behaviour to show that dialog instead of using the JavaScript alert() and confirm() functions. For type " confirm" one button must have a stm-confirm attribute on it (boolean attribute). This designates it as the confirm button for the dialog, overriding the default stm-confirm attribute behaviour
Also add:
- stm-data-form attribute which references a form element to send data from along with the action
- stm-selected which can set the selected value on a `<select>` element.
- stm-open which specifies an element to "open" when clicked (e.g. a `<dialog>`) by ID. If the element specifying the `stm-open` attribute also has a `stm-action` then the open happens after the action successfully competes.
- stm-close does the opposite to `stm-open` also completing only after any actions if present.
- stm-dialog can be used only on `<dialog>` elements and can be set to either "confirm" or "alert". Statum will automatically override the default Statum alert and confirm behaviour to show that dialog instead of using the JavaScript `alert()` and `confirm()` functions. For type " confirm" one button must have a `stm-confirm` attribute on it (boolean attribute). This designates it as the confirm button for the dialog, overriding the default `stm-confirm` attribute behaviour
Statum friction points (from the Percipio migration)
Concrete issues, footguns, and missing ergonomics encountered while building a real
app (Percipio) on Statum. Each item lists what happened, the impact, and a
suggested improvement. Items are grouped by side and roughly ordered by how
much pain they caused.
Already noted
stm-data-form — an attribute letting an action element (e.g. a button)
reference a <form> whose named inputs to send with the action. Would have
avoided the "submit vs save-draft share one form" gymnastics
(statum.jscollectData only reads form inputs when the trigger is a
<form>, so a co-equal "Save draft" button sent an empty body).
stm-selected — set the selected value on a <select>. Would have avoided
the per-option stm-attribute.selected="q.skill == '…'" verbosity
(stm-attribute.value on a <select> only calls setAttribute('value', …),
which does not change the selected option).
Client / JS side
1. Slot names that shadow JS globals silently break stm-if (blank page) — severe
Naming a slot eval made every stm-if="… eval == null …" evaluate wrong: inside
with(scope){ … }, an identifier not in scope resolves to the global (eval
is the built-in function), so eval == null was always false and eval != null
always true. Result: a fully blank page, no console error.
Impact: hours of debugging; the page was blank with zero diagnostics.
Improve: 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.
2. Referencing an unset slot makes the whole expression silently falsy — severe
evalExpr is new Function('scope','with(scope){try{return (EXPR);}catch(e){return undefined;}}').
If EXPR mentions a slot that isn't currently held, the reference throws a
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 — there's no error.
Improve: use an evaluator that resolves missing scope members to undefined
(not throw), so unsetSlot == null → undefined == null → true as expected.
Only property access on a truly-undefined value should be caught. Also: a
debug mode that logs which stm-if/expression evaluated to undefined
(and why) would have saved hours.
3. [hidden] is defeated by ordinary author CSS — severe, sneaky
Statum toggles stm-if branches and stm-for holders 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 at the top of the list.
Impact: "phantom empty entry" with no clue it was a CSS-specificity issue.
Improve: 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.
4. stm-text on an editable <input>/<textarea> resets the user's typing
Binding a form field's value with stm-text is a one-way state→DOM binding; 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 I worked around it by having
the action echo the saved values back via an update.
Improve: 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 while focused), and/or document the pattern for editable fields.
5. collectData ignores the enclosing form for non-<form> triggers
A <button stm-action=…> inside a <form> collects only its own stm-data-*
attributes, not the form's named inputs. (Covered by the stm-data-form note;
reiterating the symptom — multiple data-collecting actions on one form are
impossible today without a hidden intent field + onclick hack.)
6. No first-class spinner/busy binding
setBusyState adds the stm-busy-class classes to the form (the action
element), and disabling cascades to children — fine — but to actually show a
spinner I had to hand-roll <span class="spinner"> elements plus CSS that keys off
.btn-loading. A built-in stm-spinner (inject a spinner while busy) would be a
nice ergonomic.
Server / Vala side
7. int64 values segfault during JSON serialisation — severe
GObjectMapping.to_properties → InvercargillJson from_element crashes at
node.set_int(element.as<int64?>()) for any int64-typed value. GDB backtrace
confirmed the segfault in encryption_provider_author_properties →
JsonElement.from_properties. (as<int64?>() on an int64 ValueElement returns
something that crashes Json.Node.set_int.)
Impact: the login action hard-crashed the whole server (segfault) until I
purged every int64 from slot state.
Improve: fix the int64 path in from_element/ValueElement.as<int64?>.
This is the single most dangerous footgun — it crashes the process.
8. int (32-bit) serialises as a string, not a number
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
apparently isn't reached for ValueElements; it falls through to the
assignable_to<string>() transform fallback.) JS coercion mostly hides it for
display, but it breaks round-tripping.
Impact: slot numbers render as quoted strings; server-side get_int on a
round-tripped value fails (the encrypted JSON stored a string).
Improve: make to_properties/from_element emit numbers for numeric
ValueElements consistently.
9. A bool field in encrypted/private data segfaults
set_private_typed → author_properties → from_properties crashes on a bool
property (same family as #7). The public path handles bools fine; the
encryption path does not.
Impact: had to strip bools out of AuthPrivate and derive admin status
from the signed public role instead.
Improve: make the encryption serialisation path use the same robust
serialiser as the public path (don't crash on bool/int64).
Object[] / Lot<T> / Gee.List properties are silently skipped by
to_properties. 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. For a real app
(where nearly every page is a list) this is the dominant source of boilerplate.
Impact: wrote a custom StateJson.list<T>(lot) helper; can never use the
typed-model convenience for list pages; update_held is effectively dead for
real slots.
Improve: 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 common "list of row models" pattern is one
call instead of ~10 lines of PropertyDictionary plumbing.
11. TypedStatumAction<TPrivate>.request_private is broken (typeof(TPrivate) → void)
request_private calls GObjectMapping.from_properties(typeof(TPrivate), …), but
at runtime typeof(TPrivate) resolves to void (Vala generic erasure) →
g_object_new_with_properties: assertion G_TYPE_IS_OBJECT failed → the private
data maps to null. Had to bypass it and read request.action_private manually
via a per-action read() helper.
Impact: every typed action's request_private silently returned null.
Improve: work around the Vala typeof(T)-in-generics issue (pass the GType
explicitly from the subclass, 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.)
12. Action-private decryption failures are silent
decrypt_action_private returns an empty PropertyDictionary on any failure
(namespace mismatch, bad blob) instead of throwing/log; requires_valid_private
is false by default, so a mis-sealed action just runs with an empty context.
Impact: "retry button does nothing / bad_private" style bugs are invisible;
the action happily proceeded with attempt_id == 0.
Improve: default requires_valid_private to true for typed actions (it
is, but for plain actions consider it too), and log decryption failures
with the namespace so mismatches are diagnosable.
13. The topic system can't push list payloads
A topic's StateModifier serialises via to_properties, so it can only carry
scalar/registered-object state — not a per-question results list. I couldn't use
topics for live eval results and fell back to a manual
state_service.update(key, …) with a stashed slot key on the background worker.
Improve: 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 the common "job finishes → update the client's
view" case doesn't require hand-stashing slot keys on the worker.
14. No built-in auth / identity primitive
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. Getting the slot shape, scope, key persistence, and the
"rebuild-each-entrypoint" pattern right was a large part of the effort.
Improve: 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.
15. Static crypto keys are required for persistent sessions — but only a console whisper
Without web-config.json static keys, every signed SESSION/DEVICE slot is
invalidated on restart (ephemeral keys) → all users logged out. There's only a
quiet console warning at startup.
Improve: fail loud (or a very prominent warning) when slots are used for
identity without persistent keys, or auto-generate + persist keys by default.
16. Properties vs PropertyDictionary cast fragility
GObjectMapping.to_properties returns the Properties interface, but to add a
list/nested value I had to cast to the concrete PropertyDictionary to use its
indexer ((PropertyDictionary) to_properties(pub)). Fragile and undocumented.
Improve: have to_properties return PropertyDictionary directly (or give
Properties a typed setter / set_json(key, element) method).
17. update_held<T> is effectively unusable for real models
Because of #10, update_held (which round-trips through to_properties/from_properties)
can't preserve list fields — it would drop them. The real pattern is "reload from
DB and update(key, State)". This isn't documented and update_held looks
temptingly usable until your list vanishes.
Improve: either lift the list limitation (so update_held is safe) or
document loudly that update_held is scalar-only and the canonical mutation
pattern is rebuild-from-DB + update.
18. Page route params don't reach actions
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 — fine, but verbose and easy to forget.
Improve: a first-class "page context" slot/mechanism that actions can read
without per-action sealing (e.g. automatically include the originating page's
resolved route params in StatumRequest).
Cross-cutting / DX
19. Blank pages with zero diagnostics
The combination of #1, #2, and #3 produces blank pages (not even the preloader)
with no console error and no server error. This was the hardest class of bug.
A "statum debug" mode that logs: which slots are in scope, which stm-if
evaluated falsy and why, and any with(scope) ReferenceErrors, would be
transformative for adoption.
20. X-Statum-Slot / X-Statum-Private header shapes were hard to construct for testing
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. A short "testing Statum handlers with curl" recipe in the docs (or a
helper that prints the exact headers a client would send) would help a lot.
21. Documentation gaps vs the example
The example app 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. A richer example (or a "patterns" doc) covering lists + private + actions
would have prevented most of the above.
If I had to rank the top 5
#7 int64 segfault — crashes the process; silent and catastrophic.
#2 unset-slot → silent falsy → blank page — no diagnostics; affects every
page that references an optional slot.
#10 no list serialisation — makes the typed-model API unusable for real
apps and forces verbose PropertyDictionary plumbing everywhere.
#3[hidden] vs author CSS — sneaky, app-wide risk, trivial to fix in the
library.
#11 broken request_private — a documented API that silently returns null.
# Statum friction points (from the Percipio migration)
Concrete issues, footguns, and missing ergonomics encountered while building a real
app (Percipio) on Statum. Each item lists **what happened**, the **impact**, and a
suggested **improvement**. Items are grouped by side and roughly ordered by how
much pain they caused.
---
## Already noted
- **`stm-data-form`** — an attribute letting an action element (e.g. a button)
reference a `<form>` whose named inputs to send with the action. Would have
avoided the "submit vs save-draft share one form" gymnastics
(`statum.js` `collectData` only reads form inputs when the trigger *is* a
`<form>`, so a co-equal "Save draft" button sent an empty body).
- **`stm-selected`** — set the selected value on a `<select>`. Would have avoided
the per-option `stm-attribute.selected="q.skill == '…'"` verbosity
(`stm-attribute.value` on a `<select>` only calls `setAttribute('value', …)`,
which does not change the selected option).
---
## Client / JS side
### 1. Slot names that shadow JS globals silently break `stm-if` (blank page) — **severe**
Naming a slot `eval` made every `stm-if="… eval == null …"` evaluate wrong: inside
`with(scope){ … }`, an identifier not in scope resolves to the **global** (`eval`
is the built-in function), so `eval == null` was always `false` and `eval != null`
always `true`. Result: a fully blank page, no console error.
- **Impact:** hours of debugging; the page was blank with zero diagnostics.
- **Improve:** 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`.
### 2. Referencing an *unset* slot makes the whole expression silently falsy — **severe**
`evalExpr` is `new Function('scope','with(scope){try{return (EXPR);}catch(e){return undefined;}}')`.
If `EXPR` mentions a slot that isn't currently held, the reference throws a
`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 — there's no error.
- **Improve:** use an evaluator that resolves missing scope members to `undefined`
(not throw), so `unsetSlot == null` → `undefined == null` → `true` as expected.
Only property access on a truly-`undefined` value should be caught. Also: a
**debug mode** that logs which `stm-if`/expression evaluated to `undefined`
(and why) would have saved hours.
### 3. `[hidden]` is defeated by ordinary author CSS — **severe, sneaky**
Statum toggles `stm-if` branches and `stm-for` holders 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 at the top of the list.
- **Impact:** "phantom empty entry" with no clue it was a CSS-specificity issue.
- **Improve:** 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.
### 4. `stm-text` on an editable `<input>`/`<textarea>` resets the user's typing
Binding a form field's value with `stm-text` is a one-way state→DOM binding; 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 I worked around it by having
the action echo the saved values back via an `update`.
- **Improve:** 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 while focused), and/or document the pattern for editable fields.
### 5. `collectData` ignores the enclosing form for non-`<form>` triggers
A `<button stm-action=…>` inside a `<form>` collects only its own `stm-data-*`
attributes, not the form's named inputs. (Covered by the `stm-data-form` note;
reiterating the *symptom* — multiple data-collecting actions on one form are
impossible today without a hidden `intent` field + `onclick` hack.)
### 6. No first-class spinner/busy binding
`setBusyState` adds the `stm-busy-class` classes to the *form* (the action
element), and disabling cascades to children — fine — but to actually show a
spinner I had to hand-roll `<span class="spinner">` elements plus CSS that keys off
`.btn-loading`. A built-in `stm-spinner` (inject a spinner while busy) would be a
nice ergonomic.
---
## Server / Vala side
### 7. `int64` values segfault during JSON serialisation — **severe**
`GObjectMapping.to_properties` → InvercargillJson `from_element` crashes at
`node.set_int(element.as<int64?>())` for any `int64`-typed value. GDB backtrace
confirmed the segfault in `encryption_provider_author_properties` →
`JsonElement.from_properties`. (`as<int64?>()` on an int64 `ValueElement` returns
something that crashes `Json.Node.set_int`.)
- **Impact:** the login action hard-crashed the whole server (segfault) until I
purged every `int64` from slot state.
- **Improve:** fix the int64 path in `from_element`/`ValueElement.as<int64?>`.
This is the single most dangerous footgun — it crashes the *process*.
### 8. `int` (32-bit) serialises as a **string**, not a number
`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`
apparently isn't reached for `ValueElement`s; it falls through to the
`assignable_to<string>()` transform fallback.) JS coercion mostly hides it for
display, but it breaks round-tripping.
- **Impact:** slot numbers render as quoted strings; server-side `get_int` on a
round-tripped value fails (the encrypted JSON stored a string).
- **Improve:** make `to_properties`/`from_element` emit numbers for numeric
`ValueElement`s consistently.
### 9. A `bool` field in **encrypted/private** data segfaults
`set_private_typed` → `author_properties` → `from_properties` crashes on a `bool`
property (same family as #7). The *public* path handles bools fine; the
*encryption* path does not.
- **Impact:** had to strip `bool`s out of `AuthPrivate` and derive admin status
from the signed public `role` instead.
- **Improve:** make the encryption serialisation path use the same robust
serialiser as the public path (don't crash on bool/int64).
### 10. `GObjectMapping` cannot serialise array/list fields — **foundational**
`Object[]` / `Lot<T>` / `Gee.List` properties are silently skipped by
`to_properties`. **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. For a real app
(where nearly every page is a list) this is the dominant source of boilerplate.
- **Impact:** wrote a custom `StateJson.list<T>(lot)` helper; can never use the
typed-model convenience for list pages; `update_held` is effectively dead for
real slots.
- **Improve:** 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 common "list of row models" pattern is one
call instead of ~10 lines of `PropertyDictionary` plumbing.
### 11. `TypedStatumAction<TPrivate>.request_private` is broken (`typeof(TPrivate)` → `void`)
`request_private` calls `GObjectMapping.from_properties(typeof(TPrivate), …)`, but
at runtime `typeof(TPrivate)` resolves to `void` (Vala generic erasure) →
`g_object_new_with_properties: assertion G_TYPE_IS_OBJECT failed` → the private
data maps to `null`. Had to bypass it and read `request.action_private` manually
via a per-action `read()` helper.
- **Impact:** every typed action's `request_private` silently returned null.
- **Improve:** work around the Vala `typeof(T)`-in-generics issue (pass the GType
explicitly from the subclass, 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.)
### 12. Action-private decryption failures are silent
`decrypt_action_private` returns an empty `PropertyDictionary` on any failure
(namespace mismatch, bad blob) instead of throwing/log; `requires_valid_private`
is false by default, so a mis-sealed action just runs with an empty context.
- **Impact:** "retry button does nothing / bad_private" style bugs are invisible;
the action happily proceeded with `attempt_id == 0`.
- **Improve:** default `requires_valid_private` to `true` for typed actions (it
is, but for plain actions consider it too), and **log** decryption failures
with the namespace so mismatches are diagnosable.
### 13. The topic system can't push **list** payloads
A topic's `StateModifier` serialises via `to_properties`, so it can only carry
scalar/registered-object state — not a per-question results list. I couldn't use
topics for live eval results and fell back to a manual
`state_service.update(key, …)` with a stashed slot key on the background worker.
- **Improve:** 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 the common "job finishes → update the client's
view" case doesn't require hand-stashing slot keys on the worker.
### 14. No built-in auth / identity primitive
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. Getting the slot shape, scope, key persistence, and the
"rebuild-each-entrypoint" pattern right was a large part of the effort.
- **Improve:** 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.
### 15. Static crypto keys are required for persistent sessions — but only a console whisper
Without `web-config.json` static keys, every signed `SESSION`/`DEVICE` slot is
invalidated on restart (ephemeral keys) → all users logged out. There's only a
quiet console warning at startup.
- **Improve:** fail loud (or a very prominent warning) when slots are used for
identity without persistent keys, or auto-generate + persist keys by default.
### 16. `Properties` vs `PropertyDictionary` cast fragility
`GObjectMapping.to_properties` returns the `Properties` interface, but to add a
list/nested value I had to cast to the concrete `PropertyDictionary` to use its
indexer (`(PropertyDictionary) to_properties(pub)`). Fragile and undocumented.
- **Improve:** have `to_properties` return `PropertyDictionary` directly (or give
`Properties` a typed setter / `set_json(key, element)` method).
### 17. `update_held<T>` is effectively unusable for real models
Because of #10, `update_held` (which round-trips through `to_properties`/`from_properties`)
can't preserve list fields — it would drop them. The real pattern is "reload from
DB and `update(key, State)`". This isn't documented and `update_held` looks
temptingly usable until your list vanishes.
- **Improve:** either lift the list limitation (so `update_held` is safe) or
document loudly that `update_held` is scalar-only and the canonical mutation
pattern is rebuild-from-DB + `update`.
### 18. Page route params don't reach actions
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 — fine, but verbose and easy to forget.
- **Improve:** a first-class "page context" slot/mechanism that actions can read
without per-action sealing (e.g. automatically include the originating page's
resolved route params in `StatumRequest`).
---
## Cross-cutting / DX
### 19. Blank pages with zero diagnostics
The combination of #1, #2, and #3 produces *blank pages* (not even the preloader)
with **no console error and no server error**. This was the hardest class of bug.
A "statum debug" mode that logs: which slots are in scope, which `stm-if`
evaluated falsy and why, and any `with(scope)` ReferenceErrors, would be
transformative for adoption.
### 20. `X-Statum-Slot` / `X-Statum-Private` header shapes were hard to construct for testing
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. A short "testing Statum handlers with curl" recipe in the docs (or a
helper that prints the exact headers a client would send) would help a lot.
### 21. Documentation gaps vs the example
The example app 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. A richer example (or a "patterns" doc) covering lists + private + actions
would have prevented most of the above.
---
## If I had to rank the top 5
1. **#7 int64 segfault** — crashes the process; silent and catastrophic.
2. **#2 unset-slot → silent falsy → blank page** — no diagnostics; affects every
page that references an optional slot.
3. **#10 no list serialisation** — makes the typed-model API unusable for real
apps and forces verbose `PropertyDictionary` plumbing everywhere.
4. **#3 `[hidden]` vs author CSS** — sneaky, app-wide risk, trivial to fix in the
library.
5. **#11 broken `request_private`** — a documented API that silently returns null.
Server side attributes
pstm-prefixpstm-ifwith all possible pages pre-rendered and the correct one sent based on predicate matrix. Works withpstm-elseandpstm-else-if<pstm-guard>element in the<head>tag can cause an error page to be sent when a predicate isn't met.<pstm-template>element in the<head>tag.<pstm-name>in head tag.<pstm-uri>elements in head tag define (templated) routes for the page.<pstm-fragment><pstm-input>element in head tag that defines a variable that the fragment accepts as an input. Translates to astm-def-x-asattribute when composited<pstm-title>translates to a<title>tag but with extra attributes possible for composition, e.g. a template could define<pstm-title suffix> | My App</pstm-title>to append text to the child's title.prefixalso supported.<pstm-content>used in templates as the child outlet.pstm-materialise-asattribute can be applied to abodytag of a fragment or template child to define what type of element thebodytag should be converted to. Defaults todiv<pstm-fragment name="fragName">embeds a fragment into the template. Any attributes matching apstm-inputof the target are translated tostm-def-x-asattributespstm-res-xattribute takes a resource name and translates it to a full resource URL e.g./_statum/resource/xxxand assigns that to attributexe.g.<img pstm-res-src="logo.webp" />might translate to<img src="/_statum/resources/logo.webp" />Client side additions
subscribeandunsubscribedirectives.notifydirective withtypeandmessagedefault handler triggers JavaScriptalertonErrorHandler,onConfirmHandler, andonNotifyHandlerto JS API.stm-def-x-asattribute allows defining a variable available to this element and child elements.pstm-*tags and add a console warning.GET /_statum/channelas an SSE source in a shared worker - channel ID sent in response header. Connection is established on firstsubscribedirective in the session. Connection is closed when all subscriptions have been unsubscribed.PATCH /_statum/channelto manage the channel's subscriptions (to subscribe or unsubscribe from slots)sessionscoped slots instead of the cookie hackstm-markdownattribute (like stm-html and stm-text) escapes all HTML so can be from untrusted input.stm-h-demotionattribute that demotes all child header tags by a particular amount (must occurr after stm-html and stm-markdown)formdirective allowing submit (with optional action and URI) and reset of a form on the template by IDas_atdate is before the last update of a stateAlso add:
<select>element.<dialog>) by ID. If the element specifying thestm-openattribute also has astm-actionthen the open happens after the action successfully competes.stm-openalso completing only after any actions if present.<dialog>elements and can be set to either "confirm" or "alert". Statum will automatically override the default Statum alert and confirm behaviour to show that dialog instead of using the JavaScriptalert()andconfirm()functions. For type " confirm" one button must have astm-confirmattribute on it (boolean attribute). This designates it as the confirm button for the dialog, overriding the defaultstm-confirmattribute behaviourStatum friction points (from the Percipio migration)
Concrete issues, footguns, and missing ergonomics encountered while building a real app (Percipio) on Statum. Each item lists what happened, the impact, and a suggested improvement. Items are grouped by side and roughly ordered by how much pain they caused.
Already noted
stm-data-form— an attribute letting an action element (e.g. a button) reference a<form>whose named inputs to send with the action. Would have avoided the "submit vs save-draft share one form" gymnastics (statum.jscollectDataonly reads form inputs when the trigger is a<form>, so a co-equal "Save draft" button sent an empty body).stm-selected— set the selected value on a<select>. Would have avoided the per-optionstm-attribute.selected="q.skill == '…'"verbosity (stm-attribute.valueon a<select>only callssetAttribute('value', …), which does not change the selected option).Client / JS side
1. Slot names that shadow JS globals silently break
stm-if(blank page) — severeNaming a slot
evalmade everystm-if="… eval == null …"evaluate wrong: insidewith(scope){ … }, an identifier not in scope resolves to the global (evalis the built-in function), soeval == nullwas alwaysfalseandeval != nullalwaystrue. Result: a fully blank page, no console error.undefinedinstead of falling through towindow.2. Referencing an unset slot makes the whole expression silently falsy — severe
evalExprisnew Function('scope','with(scope){try{return (EXPR);}catch(e){return undefined;}}'). IfEXPRmentions a slot that isn't currently held, the reference throws aReferenceError, the innercatchreturnsundefined, sostm-ifis falsy andstm-textis empty — silently.unsetSlot == nulldoes not evaluate totrue(as plain JSundefined == nullwould), because the entire expression is swallowed by the catch.stm-ifmentionedevaluation(unset for a fresh attempt). Very hard to spot — there's no error.undefined(not throw), sounsetSlot == null→undefined == null→trueas expected. Only property access on a truly-undefinedvalue should be caught. Also: a debug mode that logs whichstm-if/expression evaluated toundefined(and why) would have saved hours.3.
[hidden]is defeated by ordinary author CSS — severe, sneakyStatum toggles
stm-ifbranches andstm-forholders with thehiddenattribute, 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 at the top of the list.[hidden]{display:none !important}fromstatum.jsitself (so it can't be overridden), or switch to a dedicatedstm-hiddenclass with!important. At minimum, document this prominently.4.
stm-texton an editable<input>/<textarea>resets the user's typingBinding a form field's value with
stm-textis a one-way state→DOM binding; on any re-evaluation (e.g. after a co-equal action'snotify) it wipes what the user just typed, resetting it to the stale state value.update.stm-value) that sets the value when first bound and then leaves it alone while the field is editable (or while focused), and/or document the pattern for editable fields.5.
collectDataignores the enclosing form for non-<form>triggersA
<button stm-action=…>inside a<form>collects only its ownstm-data-*attributes, not the form's named inputs. (Covered by thestm-data-formnote; reiterating the symptom — multiple data-collecting actions on one form are impossible today without a hiddenintentfield +onclickhack.)6. No first-class spinner/busy binding
setBusyStateadds thestm-busy-classclasses to the form (the action element), and disabling cascades to children — fine — but to actually show a spinner I had to hand-roll<span class="spinner">elements plus CSS that keys off.btn-loading. A built-instm-spinner(inject a spinner while busy) would be a nice ergonomic.Server / Vala side
7.
int64values segfault during JSON serialisation — severeGObjectMapping.to_properties→ InvercargillJsonfrom_elementcrashes atnode.set_int(element.as<int64?>())for anyint64-typed value. GDB backtrace confirmed the segfault inencryption_provider_author_properties→JsonElement.from_properties. (as<int64?>()on an int64ValueElementreturns something that crashesJson.Node.set_int.)int64from slot state.from_element/ValueElement.as<int64?>. This is the single most dangerous footgun — it crashes the process.8.
int(32-bit) serialises as a string, not a numberto_propertieson a GObject with anintfield produces a JSON string ("best_score": "-1") instead of a number. (The int32 branch infrom_elementapparently isn't reached forValueElements; it falls through to theassignable_to<string>()transform fallback.) JS coercion mostly hides it for display, but it breaks round-tripping.get_inton a round-tripped value fails (the encrypted JSON stored a string).to_properties/from_elementemit numbers for numericValueElements consistently.9. A
boolfield in encrypted/private data segfaultsset_private_typed→author_properties→from_propertiescrashes on aboolproperty (same family as #7). The public path handles bools fine; the encryption path does not.bools out ofAuthPrivateand derive admin status from the signed publicroleinstead.10.
GObjectMappingcannot serialise array/list fields — foundationalObject[]/Lot<T>/Gee.Listproperties are silently skipped byto_properties. Every list-bearing slot must be hand-built as aPropertyDictionarywith aJsonElementarray, andset_typed<T>/update_held<T>are unusable for any model containing a list. For a real app (where nearly every page is a list) this is the dominant source of boilerplate.StateJson.list<T>(lot)helper; can never use the typed-model convenience for list pages;update_heldis effectively dead for real slots.GObjectMapping(emit JSON arrays), and/or ship a built-inState.json_array<T>(lot)/State.nested(model)helper so the common "list of row models" pattern is one call instead of ~10 lines ofPropertyDictionaryplumbing.11.
TypedStatumAction<TPrivate>.request_privateis broken (typeof(TPrivate)→void)request_privatecallsGObjectMapping.from_properties(typeof(TPrivate), …), but at runtimetypeof(TPrivate)resolves tovoid(Vala generic erasure) →g_object_new_with_properties: assertion G_TYPE_IS_OBJECT failed→ the private data maps tonull. Had to bypass it and readrequest.action_privatemanually via a per-actionread()helper.request_privatesilently returned null.typeof(T)-in-generics issue (pass the GType explicitly from the subclass, or capture it another way) sorequest_privateworks as documented. (The example's counter action apparently works — worth diffing why; possibly same-compile-unit vs cross-namespace type registration.)12. Action-private decryption failures are silent
decrypt_action_privatereturns an emptyPropertyDictionaryon any failure (namespace mismatch, bad blob) instead of throwing/log;requires_valid_privateis false by default, so a mis-sealed action just runs with an empty context.attempt_id == 0.requires_valid_privatetotruefor typed actions (it is, but for plain actions consider it too), and log decryption failures with the namespace so mismatches are diagnosable.13. The topic system can't push list payloads
A topic's
StateModifierserialises viato_properties, so it can only carry scalar/registered-object state — not a per-question results list. I couldn't use topics for live eval results and fell back to a manualstate_service.update(key, …)with a stashed slot key on the background worker.PropertyDictionary(withJsonElementarrays), or provide a blessed "background worker → held slot" push keyed by a business id, so the common "job finishes → update the client's view" case doesn't require hand-stashing slot keys on the worker.14. No built-in auth / identity primitive
Identity is entirely DIY: build an
authSESSION slot, read it back fromrequest.heldin every handler, derive admin from the signed publicrole, hand-roll guards. Getting the slot shape, scope, key persistence, and the "rebuild-each-entrypoint" pattern right was a large part of the effort.request.current_user,require_admin(), a server-side guard that issues anavigate/error directive) and documented guidance on SESSION vs DEVICE scope and static-key persistence.15. Static crypto keys are required for persistent sessions — but only a console whisper
Without
web-config.jsonstatic keys, every signedSESSION/DEVICEslot is invalidated on restart (ephemeral keys) → all users logged out. There's only a quiet console warning at startup.16.
PropertiesvsPropertyDictionarycast fragilityGObjectMapping.to_propertiesreturns thePropertiesinterface, but to add a list/nested value I had to cast to the concretePropertyDictionaryto use its indexer ((PropertyDictionary) to_properties(pub)). Fragile and undocumented.to_propertiesreturnPropertyDictionarydirectly (or givePropertiesa typed setter /set_json(key, element)method).17.
update_held<T>is effectively unusable for real modelsBecause of #10,
update_held(which round-trips throughto_properties/from_properties) can't preserve list fields — it would drop them. The real pattern is "reload from DB andupdate(key, State)". This isn't documented andupdate_heldlooks temptingly usable until your list vanishes.update_heldis safe) or document loudly thatupdate_heldis scalar-only and the canonical mutation pattern is rebuild-from-DB +update.18. Page route params don't reach actions
Actions resolve to GUID endpoints, so a page's
{slug}/{id}aren't inrequest.route_params. The documented workaround is to seal context into the action's private data — fine, but verbose and easy to forget.StatumRequest).Cross-cutting / DX
19. Blank pages with zero diagnostics
The combination of #1, #2, and #3 produces blank pages (not even the preloader) with no console error and no server error. This was the hardest class of bug. A "statum debug" mode that logs: which slots are in scope, which
stm-ifevaluated falsy and why, and anywith(scope)ReferenceErrors, would be transformative for adoption.20.
X-Statum-Slot/X-Statum-Privateheader shapes were hard to construct for testingFiguring out the reference form (
key=…; as_at=…) vs base64-frame form, and that action private travels onX-Statum-Private, took reverse-engineering the JS client. A short "testing Statum handlers with curl" recipe in the docs (or a helper that prints the exact headers a client would send) would help a lot.21. Documentation gaps vs the example
The example app 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. A richer example (or a "patterns" doc) covering lists + private + actions would have prevented most of the above.If I had to rank the top 5
PropertyDictionaryplumbing everywhere.[hidden]vs author CSS — sneaky, app-wide risk, trivial to fix in the library.request_private— a documented API that silently returns null.