Make Statum actions first-class and ergonomic:
Series<DirectiveDto>./_statum/action/{guid},
resolved by a single parameterised endpoint (mirrors Spry's
ComponentEndpoint/PathProvider). StatumConfigurator.add_action<T>(route)
is removed.Object subclass with public T foo { get; set; } properties; the framework
maps it to/from Properties via GObject property introspection (reusing the
pattern in Invercargill's ObjectPropertyAccessor — list_properties /
get_property / Element.to_value / ValueElement). No per-model mapper.protected DirectiveBuilder
directives() on StatumAction/StatumEntrypoint starts the builder;
handle() returns the builder and the framework calls build() on it.ActionRegistry (singleton, src/ActionRegistry.vala) — the Statum
analogue of Spry's PathProvider:
register<TAction>() — assigns Uuid.string_random() → typeof(TAction)
(guid → type, type → guid), idempotent.guid_for<TAction>() — resolves a type to its assigned GUID.resolve(string guid) → Type? for dispatch.namespace_for<TAction>() → typeof(TAction).name() (the seal namespace).ActionEndpoint (singleton, src/ActionEndpoint.vala) — a single endpoint
registered at /_statum/action/{guid} (method handling: see A3). On a request
it resolves guid → Type, resolves the action from the request scope, lets the
base StatumAction.handle_request do its work (resolve held, decrypt private,
parse form, build request), then renders directives. Conceptually it is the
type-resolving shim; the per-request logic stays in StatumAction.StatumModule registers ActionRegistry (singleton) and the single
ActionEndpoint route.StatumConfigurator.action<TAction>() calls ActionRegistry.register<T>()
and container.register_scoped<TAction>() (resolvable by type for dispatch).
Replaces add_action<T>(route).StatumAction<TPrivate> (src/StatumHandlers.vala) — generic base where
TPrivate : Object. Provides:
protected TPrivate request_private — the decrypted, typed private data
(mapped from request.action_private via GObject introspection; null if
absent). Cached per request.this.get_type().name() (the concrete action
type), so blobs are scoped per action type.StatumAction remains for actions that carry no private data
(its request_private is absent; request.action_private Properties is
still available for advanced/untyped use).GObjectMapping helper (src/GObjectMapping.vala) — reuses the
ObjectPropertyAccessor pattern:
Properties to_properties<T>(T model) — iterate list_properties(),
get_property into a Value, wrap (ValueElement/NativeElement by type),
build a PropertyDictionary.T from_properties<T>(Properties props) — iterate list_properties(),
look up each property name in props, Element.to_value(prop.value_type),
set_property. Missing keys are skipped (leave default).protected ActionDto action<TAction, TPrivate>(TPrivate model, string method = "POST")
on the handler base (delegates to ActionRegistry + encryption_provider):
seals model under namespace_for<TAction>(), builds
ActionDto { uri = "/_statum/action/" + guid_for<TAction>(), method, private = blob }.ActionDto.to_element() — serialise via ActionDto.get_mapper() to a
JsonElement so it embeds cleanly as a public-data value:
state.public_data["checkout"] = action<CheckoutAction, CheckoutPrivate>(order).to_element();action<TAction, TPrivate>(TPrivate model) requires the
caller to name TPrivate, matching the handler's StatumAction<TPrivate>.DirectiveBuilder (src/DirectiveBuilder.vala) — started via
protected DirectiveBuilder directives() on StatumAction/StatumEntrypoint
(bound to the handler's state_service). Fluent, self-returning:
return directives()
.set(slot) // fresh slot: sign_slot -> set
.update(key, state) // mutate + auto-push to channels + sign -> set
.set_frame(frame) // pre-signed frame -> set
.clear(key) // clear directive
.subscribe(key) // realtime subscribe
.unsubscribe(key)
.transmit(key, retry) // transmit directive
.notify(kind, message) // notify directive
.error(message, code, data)// error directive
.post(uri, data) // terminal form-post
.navigate(uri); // terminal navigate
set/update are intents recorded in declaration order; pure directives
(clear/navigate/notify/…) are stored inline. build() is async and
materialises intents in order (update yields to push to channels; set
calls sign_slot), preserving ordering, then returns Lot<DirectiveDto>.DirectiveBuilder.to_lot() is an alias for build() for completeness.Handler signature change — handle() returns the builder, not a lot:
public abstract async DirectiveBuilder handle() throws GLib.Error;
The base handle_request/entrypoint caller does:
var builder = yield handle();
var directives = yield builder.build();
return StatumDirectives.to_result(directives);
ActionRegistry (src/ActionRegistry.vala): GUID↔type maps,
register<T>, guid_for<T>, resolve, namespace_for<T>.ActionEndpoint (src/ActionEndpoint.vala): single
/_statum/action/{guid} endpoint; resolve guid → type → scoped instance;
delegate to StatumAction.handle_request. 404 on unknown guid.ActionEndpoint so it accepts the
methods actions use (GET/POST at minimum; verify EndpointRoute method
matching in Astralis — if a route can be method-agnostic, prefer that; else
register for GET+POST+PUT+PATCH+DELETE). Document the chosen behaviour.StatumModule: register ActionRegistry singleton + the
ActionEndpoint route.StatumConfigurator.action<T>(): register type + scoped registration;
remove add_action<T>(route).GObjectMapping (src/GObjectMapping.vala): to_properties<T> /
from_properties<T> via GObject introspection (reuse
ObjectPropertyAccessor logic + Element.to_value/ValueElement). Unit-test
round-trip for primitives/strings/enums and a nested Object property.StatumAction<TPrivate>: generic base; typed request_private
(cached); per-action-type namespace from get_type().name(); decrypt via
encryption_provider.read_properties(namespace, blob) then
from_properties<TPrivate>. Keep non-generic StatumAction for private-less
actions; share the handle_request/form/held logic.protected ActionDto action<TAction, TPrivate>(TPrivate
model, string method) on the handler base; ActionDto.to_element() for
embedding in public data.Properties getter — add a Properties extension/static
helper get_native<T>(key) (via try_get + Element.as<T>/as_*_or_null)
to remove the read-boilerplate noted in the gaps review.DirectiveBuilder (src/DirectiveBuilder.vala): ordered item list
(pure directives + intents), fluent mutators, async build() materialising
intents (set→sign_slot, update→state_service.update), returning
Lot<DirectiveDto>.directives() on handler bases; change handle() to return
DirectiveBuilder; update StatumAction.handle_request and the
EntrypointEndpoint to call build().StatumDirectives.to_result as the renderer (unchanged).HomeEntrypoint embeds a login action ref (no private) into a page-scoped
slot's public data; returns it via directives().set(slot).LoginAction : StatumAction reads the form name, builds an auth slot
(with a typed AuthPrivate { is_admin }), embeds a logout action ref into
auth public data, returns directives().set(auth_slot).navigate("/").LogoutAction : StatumAction clears auth via
directives().clear(key).navigate("/").home.html uses stm-action="page.login" / stm-action="auth.logout".Main.vala uses statum.action<LoginAction>() / statum.action<LogoutAction>()./_statum/action/{guid}, the handler reads typed private, and the
directive response drives navigation/state. Verify a tampered/swapped
X-Statum-Private is rejected (namespace mismatch).// Private model (GObject properties -> introspected)
public class CheckoutPrivate : Object {
public string order_id { get; set; }
public int amount { get; set; }
}
// Action with typed private
public class CheckoutAction : StatumAction<CheckoutPrivate> {
public override async DirectiveBuilder handle() throws GLib.Error {
var p = request_private; // typed CheckoutPrivate
// ... fulfil p.order_id / p.amount ...
return directives().navigate("/done");
}
}
// Registration (GUID auto-assigned)
statum.action<CheckoutAction>();
// Authoring a reference with private data, embedded in a state update
public override async DirectiveBuilder handle() {
var order = new CheckoutPrivate { order_id = "abc", amount = 4999 };
var pub = new PropertyDictionary();
pub["checkout"] = action<CheckoutAction, CheckoutPrivate>(order).to_element();
var state = new State() { type_name = "cart", public_data = pub, private_data = new PropertyDictionary() };
var slot = state_service.new_slot(Scope.SESSION, state);
return directives().set(slot);
}
Client (unchanged): <button stm-action="cart.checkout">Checkout</button> →
POST /_statum/action/{guid} with X-Statum-Private; CheckoutAction reads
typed request_private.
StatumConfigurator.add_action<T>(route) → action<T>() (GUID).StatumAction.handle() / StatumEntrypoint.handle() return DirectiveBuilder,
not Lot<DirectiveDto>./_statum/action/{guid} and are referenced from state.PropertyMapper override for private models with non-GValue-friendly
nesting (the GObjectMapping helper covers GObject-property types; exotic
transforms can be added later).stm-action bindings / type-checked client contracts.