phase-2-plan.md 12 KB

Phase 2 — Actions API: typed private data, GUID endpoints, fluent directives

Goal

Make Statum actions first-class and ergonomic:

  1. Create actions with private data to send back in state updates — author a strongly-typed private model, seal it into an action reference, embed the reference in a snapshot's public data, and read it back typed in the handler.
  2. Fluent directive builder — build the response directives with a chained API instead of hand-assembling Series<DirectiveDto>.
  3. Couple strongly-typed private data models to actions — an action declares its private model type; the framework maps it to/from the sealed blob via GObject introspection, seals it under a per-action-type namespace, and auto-maps every action to a GUID endpoint (Spry-style — no hand-picked URIs).

Decisions (confirmed)

  • Endpoints: GUID only. Every action is served at /_statum/action/{guid}, resolved by a single parameterised endpoint (mirrors Spry's ComponentEndpoint/PathProvider). StatumConfigurator.add_action<T>(route) is removed.
  • Typed private models: GObject introspection. A private model is a 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 ObjectPropertyAccessorlist_properties / get_property / Element.to_value / ValueElement). No per-model mapper.
  • Per-action-type namespace. Each action type's private blob is sealed under a namespace derived from the action type, so one action cannot decrypt another's private blob (defence in depth).
  • Fluent builder started from the handler. A protected DirectiveBuilder directives() on StatumAction/StatumEntrypoint starts the builder; handle() returns the builder and the framework calls build() on it.

Design overview

Action registration & dispatch (Spry-style GUIDs)

  • 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).

Strongly-typed private data

  • 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.
    • The action's seal namespace is this.get_type().name() (the concrete action type), so blobs are scoped per action type.
    • Non-generic 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).
  • Authoring references — handlers embed action references in snapshot public data so the client can invoke them:
    • 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();
    • Compile-time safety: action<TAction, TPrivate>(TPrivate model) requires the caller to name TPrivate, matching the handler's StatumAction<TPrivate>.

Fluent directive builder

  • 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 changehandle() 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);
    

Tasks

Subsystem A — GUID action dispatch

  • A1 ActionRegistry (src/ActionRegistry.vala): GUID↔type maps, register<T>, guid_for<T>, resolve, namespace_for<T>.
  • A2 ActionEndpoint (src/ActionEndpoint.vala): single /_statum/action/{guid} endpoint; resolve guid → type → scoped instance; delegate to StatumAction.handle_request. 404 on unknown guid.
  • A3 Route method handling: register 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.
  • A4 StatumModule: register ActionRegistry singleton + the ActionEndpoint route.
  • A5 StatumConfigurator.action<T>(): register type + scoped registration; remove add_action<T>(route).

Subsystem B — Typed private data

  • B1 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.
  • B2 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.
  • B3 Authoring helper: protected ActionDto action<TAction, TPrivate>(TPrivate model, string method) on the handler base; ActionDto.to_element() for embedding in public data.
  • B4 Minor: typed 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.

Subsystem C — Fluent directives

  • C1 DirectiveBuilder (src/DirectiveBuilder.vala): ordered item list (pure directives + intents), fluent mutators, async build() materialising intents (setsign_slot, updatestate_service.update), returning Lot<DirectiveDto>.
  • C2 directives() on handler bases; change handle() to return DirectiveBuilder; update StatumAction.handle_request and the EntrypointEndpoint to call build().
  • C3 Keep StatumDirectives.to_result as the renderer (unchanged).

Subsystem D — Migration & validation

  • D1 Update the example to use the new API end-to-end:
    • 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>().
  • D2 Build + runtime smoke test: confirm the client resolves embedded refs, POSTs to /_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).

API sketch (end-to-end)

// 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.

Breaking changes (phase-1 API, pre-release)

  • StatumConfigurator.add_action<T>(route)action<T>() (GUID).
  • StatumAction.handle() / StatumEntrypoint.handle() return DirectiveBuilder, not Lot<DirectiveDto>.
  • Actions are no longer addressable by chosen URI; they live under /_statum/action/{guid} and are referenced from state.

Out of scope / future

  • Stable, deploy-persistent action IDs (GUIDs are random per process start; fine for app-internal refs that are re-embedded each entrypoint load).
  • Explicit PropertyMapper override for private models with non-GValue-friendly nesting (the GObjectMapping helper covers GObject-property types; exotic transforms can be added later).
  • Generating stm-action bindings / type-checked client contracts.