Named phase-3 to avoid clobbering the already-implemented
phase-2-plan.md(Actions API). Rename if you prefer.
web-config.json so frames/private blobs survive a server restart.<pstm-uri> templates — routes like /cats/{species}/details, with
entrypoints reading the captured values (typed) like Astralis route params.X-Statum-Private can't be
decrypted/verified, return an error directive and do not run the handler.statum section of
web-config.json; a statum-genkeys tool prints them; no libsodium vapi
change (the providers accept configured keys instead of generating).ActionDto) fields that
round-trip as nested JSON. GObjectMapping gains a Type → mapper registry so
Object-typed properties (ActionDto, and any app-registered model) map
recursively; primitives stay boilerplate-free.TypedStatumAction<TPrivate>, an absent or undecryptable
X-Statum-Private yields an error directive (message
"Action private data could not be verified", code "bad_private"), overridable
via a virtual member; the handler is not called. Plain StatumAction is
unaffected.GObjectMapping registry (nested objects) — src/GObjectMapping.valaToday to_properties/from_properties only handle GValue-primitive properties.
Extend them so an Object-typed property whose Type is registered maps via
that type's PropertyMapper:
Add a non-generic adapter + registry:
internal interface ObjectPropertyMapper : Object {
public abstract Properties map_from_object(Object o) throws GLib.Error;
public abstract Object materialise(InvercargillJson.JsonObject o) throws GLib.Error;
}
public static void register_mapper<T>(PropertyMapper<T> mapper) // wraps in adapter, keyed by typeof(T)
to_properties: when a property's spec.value_type is a registered Object
type, call map_from_object(get_property value) → Properties →
JsonElement.from_properties(...) and store that (mirrors today's manual
props["bump"] = dto.to_element()). Primitives unchanged.
from_properties: for a registered Object property, read the stored
JsonElement, materialise it, set_property. Primitives unchanged.
Unregistered Object properties: error at (de)serialise time (catches mistakes
early) rather than silently dropping.
The framework registers Model.ActionDto:
GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper())
in StatumModule.
A model therefore declares action refs as typed fields:
public class CartPublic : Object {
public int value { get; set; }
public Model.ActionDto bump { get; set; } // round-trips as a nested ActionDto
}
src/StatumHandlers.vala, src/DirectiveBuilder.vala, src/StateService.valaAuthoring & reading typed public/private data, all backed by GObjectMapping:
Typed held access on StatumRequest:
protected TPublic? held<TPublic>(string type_name) // maps held[type].@public → TPublic, or null
(Mirrors the existing typed request_private.)
Typed set on DirectiveBuilder:
directives().set<TPublic>(string type, Scope scope, TPublic pub)
directives().set<TPublic, TPrivate>(string type, Scope scope, TPublic pub, TPrivate priv)
These map the model(s) to Properties (private via encryption_provider),
state_service.new_slot + sign, and emit a set — collapsing today's
PropertyDictionary + set_native + new State + sign_slot + .set(slot).
Typed update on DirectiveBuilder:
directives().update_held<TPublic>(string type, owned UpdateMutator<TPublic> mutator)
public delegate void UpdateMutator<T>(T current);
Reads the held slot, maps its current public data → TPublic (action-ref
fields included, via the registry), runs the mutator, maps back to
Properties, and state_service.update (sign + auto-push) → set. If the
slot isn't held, an error directive is emitted instead.
Keep the existing untyped .set(Slot) / .update(key, State) for advanced use.
The bump-counter action becomes:
public class CounterPublic : Object {
public int value { get; set; }
public Model.ActionDto bump { get; set; }
}
public override async DirectiveBuilder handle() throws GLib.Error {
var delta = request_private != null ? ((!)request_private).delta : 1;
return directives().update_held<CounterPublic>("counter", c => c.value += delta);
}
and the entrypoint:
var bump = action_registry.author_private<BumpCounterAction, CounterPrivate>(new CounterPrivate { delta = 1 });
return directives().set<CounterPublic>("counter", Scope.PAGE, new CounterPublic { value = 0, bump = bump });
src/Cryptography/*.vala, src/Statum.vala, tools/statum-genkeys/SigningProvider.with_keys(uint8[] secret_key, uint8[] public_key) (sets both,
signer_id = Base64.encode(public_key)); the parameterless construct keeps
generating for dev/no-config.EncryptionProvider.with_keys(signing_sk, signing_pk, sealing_sk, sealing_pk).StatumModule injects Astralis' WebConfig, reads the statum section; if the
keys are present it constructs the providers with them, otherwise generates and
logs a warning ("no static keys configured; frames/private blobs will not
survive a restart"). Keys are base64 in web-config.json:
{
"statum": {
"signing_secret_key": "...", "signing_public_key": "...",
"encryption_signing_secret_key": "...", "encryption_signing_public_key": "...",
"encryption_sealing_secret_key": "...", "encryption_sealing_public_key": "..."
}
}
statum-genkeys tool (mirrors statum-mkres): generates the three
keypairs (Ed25519 signing; Ed25519 + X25519 for encryption) and prints the six
base64 values plus a ready-to-paste statum config block. Wired in
tools/meson.build (install: true).
<pstm-uri> templates — verify + typed access — src/StatumRequest.vala, src/EntrypointEndpoint.valaRoute-template matching already works (the configurator derives the route from
<pstm-uri>; EntrypointRouteTable.match extracts {param}; the entrypoint
receives request.route_params). This task:
/cats/{species}/details serves the static page on
navigation and that /_statum/entrypoint?uri=/cats/Tabby/details captures
species=Tabby into request.route_params.Adds typed route-param access on StatumRequest:
protected string? route(string name) // raw
protected T? route<T>(string name) // parsed (int/double/bool/string)
Documents <pstm-uri> templates in model.md (HTML API + entrypoint access).
src/StatumHandlers.valaTypedStatumAction<TPrivate> gains overridable members:
protected virtual string private_error_message { get { return "Action private data could not be verified"; } }
protected virtual string? private_error_code { get { return "bad_private"; } }
StatumAction.handle_request decrypts X-Statum-Private under the action's
namespace; for a TypedStatumAction, if the header is absent or decryption
fails, it returns directives().error(private_error_message, private_error_code)
and does not call handle(). (The current swallow-to-empty behaviour is
removed for typed actions.) Plain StatumAction (no typed private) keeps
reading request.action_private as before.
ObjectPropertyMapper adapter + GObjectMapping.register_mapper<T> and
recursive to_properties/from_properties for registered Object properties
(error on unregistered Object properties). Unit-test the round-trip for a
primitive+ActionDto-field model.StatumModule registers Model.ActionDto.StatumRequest.held<TPublic>(type) typed accessor.DirectiveBuilder.set<TPublic> / set<TPublic,TPrivate> /
update_held<TPublic>(mutator) (+ UpdateMutator<T> delegate).HomeEntrypoint, BumpCounterAction,
LoginAction) to typed models (CounterPublic, AuthPublic, …) and confirm
the bump action preserves its bump ref across updates.SigningProvider.with_keys / EncryptionProvider.with_keys.StatumModule reads WebConfig statum section; static-or-generate
with a warning.statum-genkeys tool + tools/meson.build.web-config.json keys + restart-stable-frame behaviour.{param} page serving + entrypoint capture (add a templated
example page if useful).StatumRequest.route<T>(name) typed accessor.<pstm-uri> templates in model.md.TypedStatumAction overridable error members; handle_request
short-circuits to an error directive on absent/undecryptable private.X-Statum-Private returns
the error and does not run the handler; a valid one runs.// Public model with a typed action reference
public class CartPublic : Object {
public int value { get; set; }
public Model.ActionDto bump { get; set; }
}
public class CartPrivate : Object {
public int delta { get; set; }
}
// Entrypoint: typed set, action ref authored and embedded as a field
public class CartEntrypoint : StatumEntrypoint {
public override async DirectiveBuilder handle() throws GLib.Error {
var bump = action_registry.author_private<BumpAction, CartPrivate>(new CartPrivate { delta = 1 });
return directives().set<CartPublic>("cart", Scope.PAGE, new CartPublic { value = 0, bump = bump });
}
}
// Action: typed private + typed update (action ref preserved automatically)
public class BumpAction : TypedStatumAction<CartPrivate> {
public override async DirectiveBuilder handle() throws GLib.Error {
var delta = request_private != null ? ((!)request_private).delta : 1;
return directives().update_held<CartPublic>("cart", c => c.value += delta);
}
}
// Reading a held slot, typed, in any handler
CartPublic cart = request.held<CartPublic>("cart");
string species = request.route<string>("species"); // /cats/{species}/details
TypedStatumAction now errors on bad/absent private instead of yielding an
empty model — handlers that relied on request_private == null for the
"no private sent" case must instead be plain StatumAction if private is
genuinely optional..set(Slot)/.update stay.verify_frame currently checks only the local key.Properties literal/builder for ad-hoc (untyped) data.custom_target boilerplate.