# 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`. 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(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 `ObjectPropertyAccessor` — `list_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()` — assigns `Uuid.string_random()` → `typeof(TAction)` (`guid → type`, `type → guid`), idempotent. - `guid_for()` — resolves a type to its assigned GUID. - `resolve(string guid)` → `Type?` for dispatch. - `namespace_for()` → `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()`** calls `ActionRegistry.register()` and `container.register_scoped()` (resolvable by type for dispatch). Replaces `add_action(route)`. ### Strongly-typed private data - **`StatumAction`** (`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 model)` — iterate `list_properties()`, `get_property` into a `Value`, wrap (`ValueElement`/`NativeElement` by type), build a `PropertyDictionary`. - `T from_properties(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(TPrivate model, string method = "POST")` on the handler base (delegates to `ActionRegistry` + `encryption_provider`): seals `model` under `namespace_for()`, builds `ActionDto { uri = "/_statum/action/" + guid_for(), 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(order).to_element();` - Compile-time safety: `action(TPrivate model)` requires the caller to name `TPrivate`, matching the handler's `StatumAction`. ### 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: ```vala 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`. - `DirectiveBuilder.to_lot()` is an alias for `build()` for completeness. - **Handler signature change** — `handle()` returns the builder, not a lot: ```vala public abstract async DirectiveBuilder handle() throws GLib.Error; ``` The base `handle_request`/entrypoint caller does: ```vala 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`, `guid_for`, `resolve`, `namespace_for`. - **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()`**: register type + scoped registration; remove `add_action(route)`. ### Subsystem B — Typed private data - **B1 `GObjectMapping`** (`src/GObjectMapping.vala`): `to_properties` / `from_properties` 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`**: 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`. Keep non-generic `StatumAction` for private-less actions; share the `handle_request`/form/held logic. - **B3 Authoring helper**: `protected ActionDto action(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(key)` (via `try_get` + `Element.as`/`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 (`set`→`sign_slot`, `update`→`state_service.update`), returning `Lot`. - **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()` / `statum.action()`. - **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) ```vala // 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 { 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(); // 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(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): `` → POST `/_statum/action/{guid}` with `X-Statum-Private`; `CheckoutAction` reads typed `request_private`. ## Breaking changes (phase-1 API, pre-release) - `StatumConfigurator.add_action(route)` → `action()` (GUID). - `StatumAction.handle()` / `StatumEntrypoint.handle()` return `DirectiveBuilder`, not `Lot`. - 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.