clanker 2 周之前
父節點
當前提交
1616fce25d

+ 53 - 19
example/Actions.vala

@@ -5,13 +5,13 @@ using Invercargill.DataStructures;
 namespace Example {
 
     /**
-     * `POST /login` — creates a session-scoped `auth` slot from the submitted
-     * name and redirects the client home. The `is_admin` flag is kept in the
-     * slot's (encrypted, server-only) private data.
+     * `POST /_statum/action/{guid}` — logs in: reads the submitted name, builds a
+     * session `auth` slot (with a typed private `is_admin` flag) carrying a
+     * `logout` action reference, and redirects home.
      */
     public class LoginAction : StatumAction {
 
-        public override async Lot<Model.DirectiveDto> handle() throws GLib.Error {
+        public override async DirectiveBuilder handle() throws GLib.Error {
             var name = "guest";
             if (request.form != null) {
                 var submitted = request.form.get_field("name");
@@ -24,41 +24,75 @@ namespace Example {
             var auth_public = new PropertyDictionary();
             auth_public.set_native<string>("name", name);
             auth_public.set_native<string>("role", is_admin ? "admin" : "member");
+            auth_public["logout"] = action_registry.author<LogoutAction>().to_element();
 
             var auth_private = new PropertyDictionary();
             auth_private.set_native<bool>("is_admin", is_admin);
 
-            var state = new State() {
+            var slot = state_service.new_slot(Scope.SESSION, new State() {
                 type_name = "auth",
                 public_data = auth_public,
                 private_data = auth_private
-            };
-            var slot = state_service.new_slot(Scope.SESSION, state);
-            var frame = state_service.sign_slot(slot.id);
+            });
 
-            var directives = new Series<Model.DirectiveDto>();
-            directives.add(new Model.SetDirectiveDto.with_frame((!)frame));
-            directives.add(new Model.NavigateDirectiveDto.with_uri("/"));
-            return directives;
+            return directives().set(slot).navigate("/");
         }
     }
 
     /**
-     * `POST /logout` — clears the held `auth` slot and redirects home.
+     * `POST /_statum/action/{guid}` — logs out: clears the held `auth` slot
+     * (server cache + clear directive) and redirects home.
      */
     public class LogoutAction : StatumAction {
 
-        public override async Lot<Model.DirectiveDto> handle() throws GLib.Error {
-            var directives = new Series<Model.DirectiveDto>();
-
+        public override async DirectiveBuilder handle() throws GLib.Error {
             HeldSlot auth;
             if (request.held.try_get("auth", out auth)) {
                 yield state_service.clear_slot(auth.key);
-                directives.add(new Model.ClearDirectiveDto.with_key(auth.key));
+                return directives().clear(auth.key).navigate("/");
+            }
+            return directives().navigate("/");
+        }
+    }
+
+    /**
+     * `POST /_statum/action/{guid}` — bumps the counter by the typed private
+     * `delta`, updating the held `counter` slot (sign + auto-push to subscribed
+     * channels) and re-embedding the `bump` reference in the new public data.
+     */
+    public class BumpCounterAction : TypedStatumAction<CounterPrivate> {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var delta = request_private != null ? ((!)request_private).delta : 1;
+
+            HeldSlot held_counter;
+            if (!request.held.try_get("counter", out held_counter)) {
+                return directives().error("No counter slot held");
+            }
+
+            var slot = state_service.get_slot(held_counter.key);
+            if (slot == null || ((!)slot).current_state == null) {
+                return directives().error("Counter slot not found");
             }
 
-            directives.add(new Model.NavigateDirectiveDto.with_uri("/"));
-            return directives;
+            var current = ((!)slot).current_state;
+            var value = GObjectMapping.get_int(current.public_data, "value") ?? 0;
+
+            var new_public = new PropertyDictionary();
+            new_public.set_native<int>("value", value + delta);
+            var bump_private = new CounterPrivate();
+            bump_private.delta = delta;
+            new_public["bump"] = action_registry
+                .author_private<BumpCounterAction, CounterPrivate>(bump_private)
+                .to_element();
+
+            var new_state = new State() {
+                type_name = "counter",
+                public_data = new_public,
+                private_data = new PropertyDictionary()
+            };
+
+            return directives().update(held_counter.key, new_state);
         }
     }
 }

+ 35 - 12
example/HomeEntrypoint.vala

@@ -5,26 +5,49 @@ using Invercargill.DataStructures;
 namespace Example {
 
     /**
-     * Entrypoint for the home page: hydrates a `counter` slot the template binds
-     * to with `stm-text="counter.value"`.
+     * Private data carried by the bump-counter action (strongly typed). The
+     * framework seals/reads it via GObject introspection — no mapper.
      */
-    public class HomeEntrypoint : StatumEntrypoint {
+    public class CounterPrivate : Object {
+        public int delta { get; set; }
+    }
 
-        public override async Lot<Model.DirectiveDto> handle() throws GLib.Error {
-            var directives = new Series<Model.DirectiveDto>();
+    /**
+     * Entrypoint for the home page.
+     *
+     * Demonstrates authoring action references (one with typed private data) and
+     * embedding them in state so the client can invoke them via `stm-action`,
+     * then returning them with the fluent directive builder.
+     */
+    public class HomeEntrypoint : StatumEntrypoint {
 
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            // A counter slot whose public data carries a `bump` action reference
+            // sealed with typed private data ({delta: 1}).
             var counter_public = new PropertyDictionary();
-            counter_public.set_native<int>("value", 42);
-            var counter_state = new State() {
+            counter_public.set_native<int>("value", 0);
+            var bump_private = new CounterPrivate();
+            bump_private.delta = 1;
+            counter_public["bump"] = action_registry
+                .author_private<BumpCounterAction, CounterPrivate>(bump_private)
+                .to_element();
+            var counter_slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "counter",
                 public_data = counter_public,
                 private_data = new PropertyDictionary()
-            };
-            var counter_slot = state_service.new_slot(Scope.PAGE, counter_state);
-            var counter_frame = state_service.sign_slot(counter_slot.id);
-            directives.add(new Model.SetDirectiveDto.with_frame((!)counter_frame));
+            });
+
+            // A page slot whose public data carries a private-less `login`
+            // action reference.
+            var page_public = new PropertyDictionary();
+            page_public["login"] = action_registry.author<LoginAction>().to_element();
+            var page_slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "page",
+                public_data = page_public,
+                private_data = new PropertyDictionary()
+            });
 
-            return directives;
+            return directives().set(counter_slot).set(page_slot);
         }
     }
 }

+ 6 - 6
example/Main.vala

@@ -12,13 +12,13 @@ void main(string[] args) {
         application.add_module<StatumModule>();
 
         var statum = application.configure_with<StatumConfigurator>();
-        // HomePage is generated from home.html; HomeEntrypoint hydrates it.
-        // The Statum client scripts (statum.js / statum-worker.js) are embedded in
-        // the library and served by default, so only the page-specific CSS needs
-        // registering here.
+        // HomePage is generated from home.html; HomeEntrypoint hydrates it and
+        // embeds the login/bump action references. Actions are auto-mapped to
+        // GUID endpoints; the Statum client scripts are embedded by default.
         statum.add_entrypoint_page<HomePage, HomeEntrypoint>(new EndpointRoute("/"));
-        statum.add_action<LoginAction>(new EndpointRoute("/login", Method.POST));
-        statum.add_action<LogoutAction>(new EndpointRoute("/logout", Method.POST));
+        statum.action<LoginAction>();
+        statum.action<LogoutAction>();
+        statum.action<BumpCounterAction>();
         statum.add_resource<StylesResource>();
 
         application.run();

+ 6 - 26
example/home.html

@@ -1,38 +1,18 @@
 <pstm-uri>/</pstm-uri>
 <pstm-template>main</pstm-template>
 
-<!--
-  Server-side variant (pstm-*).
+<p>
+    Counter: <span stm-text="counter.value">0</span>
+    <button stm-action="counter.bump">Bump +1</button>
+</p>
 
-  The branch is chosen by statum-mkpstm at request time from the X-Statum-Slot
-  headers the client carries. A normal browser navigation sends no such headers,
-  so the default ("guest") branch is what is first painted; the client then takes
-  over the live UI below. To see the authenticated variant directly, replay the
-  request with the stored frame:
-
-      curl http://localhost:8080/ -H "X-Statum-Slot: <base64-frame>"
--->
-<p pstm-if="auth != null">Server-rendered: signed in.</p>
-<p pstm-else>Server-rendered: browsing as a guest.</p>
-
-<hr>
-
-<p>Counter (set by the entrypoint): <span stm-text="counter.value">…</span></p>
-
-<!--
-  Client-side variant (stm-*).
-
-  Evaluated live against the held slots after the entrypoint hydrates the page.
--->
 <div stm-if="auth != null">
     <p>Welcome back, <strong stm-text="auth.name">user</strong>.</p>
-    <form stm-action="{uri: '/logout', method: 'POST'}">
-        <button type="submit">Log out</button>
-    </form>
+    <button stm-action="auth.logout">Log out</button>
 </div>
 <div stm-else>
     <p>Sign in to personalise this page.</p>
-    <form stm-action="{uri: '/login', method: 'POST'}">
+    <form stm-action="page.login">
         <label>Name <input name="name" placeholder='try "admin"'></label>
         <button type="submit">Log in</button>
     </form>

+ 30 - 1
js/statum.js

@@ -476,6 +476,22 @@
     else if (key) signatures.delete(key);
   }
 
+  /**
+   * Resolve a frame signature to its slot type and clear it. Used by the
+   * `invalidate` directive, which references a signature (not a key) because the
+   * server reports a bad signature without trusting the frame's content.
+   * @param {string} signature
+   * @param {boolean} [broadcast]
+   */
+  function clearBySignature(signature, broadcast) {
+    var typeToRemove = null;
+    store.forEach(function (entry, type) {
+      if (entry.frame && entry.frame.signature === signature) typeToRemove = type;
+    });
+    if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast });
+    else if (signature) signatures.delete(signature);
+  }
+
   /**
    * Commit a `set` to the in-memory store, persist it, and fire listeners.
    * Idempotent: if the frame's `signature` matches the last-applied signature
@@ -715,6 +731,12 @@
       switch (d.type) {
         case 'set': applySet(d); break;
         case 'clear': applyClear(d); break;
+        case 'invalidate':
+          if (d.signature != null) {
+            console.warn('[statum] server could not verify slot frame ' + d.signature + '; clearing it');
+            clearBySignature(d.signature, true);
+          }
+          break;
         case 'subscribe': channelSubscribe(d.key); break;
         case 'unsubscribe': channelUnsubscribe(d.key); break;
         case 'notify': triggerNotify(d.kind, d.message); break;
@@ -724,7 +746,14 @@
             if (d.retry) result.retry = true;
           }
           break;
-        case 'error': result.errors.push(new StatumError(d.message, d.code, d.data)); break;
+        case 'error':
+          {
+            var err = new StatumError(d.message, d.code, d.data);
+            var detail = d.code ? (d.message + ' [' + d.code + ']') : d.message;
+            console.error('[statum] error directive: ' + (detail || '(no message)'));
+            result.errors.push(err);
+          }
+          break;
         case 'navigate': result.navigate = d; break;
         case 'post': result.post = d; break;
       }

+ 15 - 2
model.md

@@ -47,6 +47,7 @@ A statum snapshot captures an aspect of the application state at a particular po
     - `"unsubscribe"`: Used to unsubscribe the client's realtime channel from a slot.
     - `"notify"`: Used to surface a transient notification to the user.
     - `"transmit"`: Used to force the client to transmit a slot's frame to the server immediately, optionally retrying the originating request afterward.
+    - `"invalidate"`: Tells the client to drop the held slot whose frame carries the given `signature`. Emitted automatically by the slot post endpoint for a frame whose signature did not verify, and by entrypoint/action endpoints for an always-send frame that failed verification. The signature is read off the frame envelope without trusting its `content`, so a corrupt frame can still be identified.
 
 Depending on the type, it will have other properties.
 
@@ -109,6 +110,18 @@ When `retry` is `true`, the originating request (the one that returned this dire
 
 Transmit is not terminal: it runs in the same pass as `set` and `clear`. The actual transmission (and any retry) occurs after the response's other non-terminal directives have been applied and the page re-rendered. When a retry is performed, the original response's terminal directives (`navigate`, `post`) and `error` directives are not applied — the retried response supersedes them.
 
+### Invalidate Directive
+
+- `signature`: The signature of the frame the server could not trust.
+
+When the server receives a frame whose signature does not verify (or whose snapshot has passed its `invalid_after` window) it returns an `invalidate` for that frame's `signature`. The signature is read straight off the frame envelope (`{content, signer, signature}`) without parsing or trusting `content`, so even a corrupt or untrustworthy frame can be identified. On receipt the client finds the held slot whose frame carries that signature and clears it — exactly as a `clear` directive would — and logs a console warning, so it stops re-sending a frame the server cannot accept.
+
+`invalidate` is emitted automatically by the slot post endpoint (`POST /_statum/slots`) for a posted frame that fails verification, and by the entrypoint/action endpoints for an always-send (`X-Statum-Slot`) frame that fails verification.
+
+### Automatic transmit on missing slots
+
+When an entrypoint or action request references (in `X-Statum-Slot`) a slot the server no longer has in its cache, the server returns a `transmit` directive with `retry: true` for that slot's key *before* running the handler. The client responds by transmitting the held frame for that key to the slot post endpoint: if the frame verifies the server re-caches it and returns a fresh `set`; if it does not verify the server returns an `invalidate` for that signature and the client drops the slot. The client then re-issues the original request (with rebuilt headers reflecting the re-cached or invalidated slots), and the handler runs normally. This makes the client resilient to a server restart or cache expiry: a stale reference is recovered transparently (or the bad slot is dropped) without the handler ever seeing a slot it cannot trust.
+
 # Statum HTTP API
 
 All endpoints that return directives do so as a JSON array of Statum Directives, served with the Content-Type `application/vnd.statum+json`. The frontend uses this content type to distinguish a Statum directive response from an ordinary HTML or JSON response.
@@ -121,11 +134,11 @@ Returns a JSON array of Statum Directives.
 
 ## Slot Post Endpoint
 
-`POST /_statum/slots`: Send a saved state snapshots frame to the server once it has reached its `transmit_after` time window. Sent as a JSON array of Statum Frames. Returns a JSON array of Statum Directives.
+`POST /_statum/slots`: Send saved state snapshot frames to the server, either once they have reached their `transmit_after` time window (a pre-flight) or in response to a `transmit` directive. Sent as a JSON array of Statum Frames. For each frame the server verifies the signature: on success it re-caches the slot and returns a refreshed `set` directive; on failure it returns an `invalidate` for that frame's signature so the client drops the slot. Returns a JSON array of Statum Directives.
 
 ## Action Endpoints
 
-The URLs of action endpoints are entirely defined by the action itself (in the `uri` property). An additional `X-Statum-Private` header should contain the `private` data of the action if present. Returns a JSON array of Statum Directives.
+Action endpoints are served at auto-assigned GUID URIs (`/_statum/action/{guid}`); the `uri` an action reference carries points at its GUID. An additional `X-Statum-Private` header should contain the `private` data of the action if present. Returns a JSON array of Statum Directives. Like the entrypoint endpoint, an action endpoint returns `transmit(retry)` for any referenced slot not in the server's cache before running the handler.
 
 ## Channel Endpoint
 

+ 235 - 0
plans/phase-2-plan.md

@@ -0,0 +1,235 @@
+# 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 `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<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:
+  ```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<DirectiveDto>`.
+  - `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<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 (`set`→`sign_slot`, `update`→`state_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)
+
+```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<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.

+ 41 - 0
src/ActionEndpoint.vala

@@ -0,0 +1,41 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * `/_statum/action/{guid}` — the single endpoint that dispatches every
+     * Statum action.
+     *
+     * The GUID is resolved by {@link ActionRegistry} to the action's type; the
+     * scoped {@link StatumAction} instance is then resolved and its
+     * {@link StatumAction.handle_request} does the per-request work (resolve held
+     * slots, decrypt `X-Statum-Private`, parse the form, run {@link
+     * StatumAction.handle}, render directives). Registered for all common verbs
+     * so an action may be authored with whichever HTTP method suits it.
+     *
+     * This mirrors Spry's single `ComponentEndpoint`/`PathProvider` model.
+     */
+    public class ActionEndpoint : Object, Endpoint {
+
+        private ActionRegistry action_registry = inject<ActionRegistry>();
+        private Inversion.Scope scope = inject<Inversion.Scope>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var guid = route_context.mapped_parameters.get_or_default("guid");
+            if (guid == null) {
+                return new HttpStringResult("Missing action id", StatusCode.BAD_REQUEST);
+            }
+
+            var type = action_registry.resolve((!)guid);
+            if (type == null || !((!)type).is_a(typeof(StatumAction))) {
+                return new HttpStringResult("No such action", StatusCode.NOT_FOUND);
+            }
+
+            var action = scope.resolve_type((!)type);
+            return yield ((StatumAction) action).handle_request(http_context, route_context);
+        }
+    }
+}

+ 111 - 0
src/ActionRegistry.vala

@@ -0,0 +1,111 @@
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Inversion;
+
+namespace Statum {
+
+    /**
+     * Resolves Statum actions to/from their auto-assigned GUID endpoints, the
+     * Statum analogue of Spry's {@link Spry.PathProvider}.
+     *
+     * Every action registered via {@link StatumConfigurator.action} is assigned a
+     * random GUID and served at `/_statum/action/{guid}` by a single
+     * {@link ActionEndpoint}. The same GUID (looked up by type) is used when an
+     * {@link Statum.Model.ActionDto} reference is authored for embedding in state.
+     *
+     * GUIDs are random per process start; this is fine because action references
+     * are re-embedded into state on every entrypoint load, so a stale GUID never
+     * survives a page render.
+     */
+    public class ActionRegistry : Object {
+
+        private Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
+
+        private Dictionary<string, Type> guid_to_type = new Dictionary<string, Type>();
+        private Dictionary<string, string> type_to_guid = new Dictionary<string, string>();
+
+        /** Registers an action type and assigns it a GUID (idempotent). */
+        public void register<TAction>() {
+            register_type(typeof(TAction));
+        }
+
+        /** Registers an action type and assigns it a GUID (idempotent). */
+        public void register_type(Type type) {
+            var name = type.name();
+            if (type_to_guid.has(name)) {
+                return;
+            }
+            var guid = Uuid.string_random();
+            guid_to_type[guid] = type;
+            type_to_guid[name] = guid;
+        }
+
+        /** The GUID assigned to `TAction` (registering it first if needed). */
+        public string guid_for<TAction>() {
+            return guid_for_type(typeof(TAction));
+        }
+
+        /** The GUID assigned to `type` (registering it first if needed). */
+        public string guid_for_type(Type type) {
+            var name = type.name();
+            string guid;
+            if (type_to_guid.try_get(name, out guid)) {
+                return guid;
+            }
+            register_type(type);
+            type_to_guid.try_get(name, out guid);
+            return guid;
+        }
+
+        /** Resolves a GUID back to its action type for dispatch, or `null`. */
+        public Type? resolve(string guid) {
+            Type type;
+            if (guid_to_type.try_get(guid, out type)) {
+                return type;
+            }
+            return null;
+        }
+
+        /** The seal namespace for `TAction`'s private data (its type name). */
+        public string namespace_for<TAction>() {
+            return typeof(TAction).name();
+        }
+
+        /** The seal namespace for `type`'s private data (its type name). */
+        public string namespace_for_type(Type type) {
+            return type.name();
+        }
+
+        /**
+         * Authors an {@link Model.ActionDto} reference for `TAction` (no private
+         * data) that the client can invoke via `stm-action`.
+         */
+        public Model.ActionDto author<TAction>(string method = "POST") {
+            return new Model.ActionDto() {
+                uri = @"/_statum/action/$(guid_for<TAction>())",
+                method = method
+            };
+        }
+
+        /**
+         * Authors an {@link Model.ActionDto} reference for `TAction` carrying
+         * `model` as sealed private data. The blob is sealed under
+         * {@link namespace_for}'s namespace, which the matching
+         * `TypedStatumAction<TPrivate>` handler decrypts with its per-type
+         * namespace.
+         *
+         * (Named distinctly from {@link author} because Vala does not permit
+         * methods that overload on generic arity.)
+         */
+        public Model.ActionDto author_private<TAction, TPrivate>(TPrivate model, string method = "POST") throws GLib.Error {
+            var blob = encryption_provider.author_properties(
+                namespace_for<TAction>(),
+                GObjectMapping.to_properties((Object) model));
+            return new Model.ActionDto() {
+                uri = @"/_statum/action/$(guid_for<TAction>())",
+                method = method,
+                @private = blob
+            };
+        }
+    }
+}

+ 157 - 0
src/DirectiveBuilder.vala

@@ -0,0 +1,157 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Mapping;
+
+namespace Statum {
+
+    /**
+     * Fluent builder for the directive list an entrypoint/action returns.
+     *
+     * Started via {@link StatumEntrypoint.directives}/{@link StatumAction.directives}
+     * (bound to the handler's {@link StateService}). Each mutator returns the
+     * builder, so directives chain:
+     *
+     * ```
+     * return directives()
+     *     .set(slot)            // fresh slot: sign_slot -> set
+     *     .update(key, state)   // mutate + auto-push to channels + sign -> set
+     *     .clear(other_key)
+     *     .notify("info", "Saved")
+     *     .navigate("/");
+     * ```
+     *
+     * `set`/`update` are intents recorded in declaration order; pure directives
+     * are stored inline. {@link build} is `async` and materialises the intents in
+     * order (`update` yields to push to subscribed channels; `set` calls
+     * `sign_slot`), preserving ordering, then returns the directives.
+     */
+    public class DirectiveBuilder : Object {
+
+        private StateService state_service;
+        private Series<Item> items = new Series<Item>();
+
+        internal DirectiveBuilder(StateService state_service) {
+            this.state_service = state_service;
+        }
+
+        /** Signs the (fresh) slot and emits a `set` directive for its frame. */
+        public DirectiveBuilder set(Slot slot) {
+            items.add(new Item() { kind = ItemKind.SLOT, slot = slot });
+            return this;
+        }
+
+        /** Emits a `set` directive for an already-signed frame. */
+        public DirectiveBuilder set_frame(Model.FrameDto frame) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.SetDirectiveDto.with_frame(frame) });
+            return this;
+        }
+
+        /** Mutates the cached slot (`update` = sign + auto-push to channels) and emits a `set`. */
+        public DirectiveBuilder update(string key, State state) {
+            items.add(new Item() { kind = ItemKind.UPDATE, key = key, update_state = state });
+            return this;
+        }
+
+        /** Emits a `clear` directive. */
+        public DirectiveBuilder clear(string key) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.ClearDirectiveDto.with_key(key) });
+            return this;
+        }
+
+        /** Emits an `invalidate` directive for a frame `signature` the server could not trust. */
+        public DirectiveBuilder invalidate(string signature) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.InvalidateDirectiveDto.with_signature(signature) });
+            return this;
+        }
+
+        /** Emits a `subscribe` directive (realtime channel). */
+        public DirectiveBuilder subscribe(string key) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.SubscribeDirectiveDto.with_key(key) });
+            return this;
+        }
+
+        /** Emits an `unsubscribe` directive. */
+        public DirectiveBuilder unsubscribe(string key) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.UnsubscribeDirectiveDto.with_key(key) });
+            return this;
+        }
+
+        /** Emits a `transmit` directive. */
+        public DirectiveBuilder transmit(string key, bool retry = false) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.TransmitDirectiveDto.with(key, retry) });
+            return this;
+        }
+
+        /** Emits a `notify` directive. */
+        public DirectiveBuilder notify(string kind, string message) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.NotifyDirectiveDto.with(kind, message) });
+            return this;
+        }
+
+        /** Emits an `error` directive. */
+        public DirectiveBuilder error(string message, string? code = null, Properties? data = null) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.ErrorDirectiveDto() {
+                message = message, code = code, data = data
+            }});
+            return this;
+        }
+
+        /** Emits a terminal `post` directive (full-page form POST). */
+        public DirectiveBuilder post(string uri, Properties data) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.PostDirectiveDto.with(uri, data) });
+            return this;
+        }
+
+        /** Emits a terminal `navigate` directive. */
+        public DirectiveBuilder navigate(string uri) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.NavigateDirectiveDto.with_uri(uri) });
+            return this;
+        }
+
+        /** Materialises intents and returns the directive lot, in declaration order. */
+        public async Lot<Model.DirectiveDto> build() throws GLib.Error {
+            var directives = new Series<Model.DirectiveDto>();
+            foreach (var item in items) {
+                switch (item.kind) {
+                    case ItemKind.DIRECTIVE:
+                        if (item.directive != null) {
+                            directives.add(item.directive);
+                        }
+                        break;
+                    case ItemKind.SLOT:
+                        if (item.slot != null) {
+                            var frame = state_service.sign_slot(((!)item.slot).id);
+                            if (frame != null) {
+                                directives.add(new Model.SetDirectiveDto.with_frame((!)frame));
+                            }
+                        }
+                        break;
+                    case ItemKind.UPDATE:
+                        var frame = yield state_service.update(item.key, item.update_state);
+                        directives.add(new Model.SetDirectiveDto.with_frame(frame));
+                        break;
+                }
+            }
+            return directives;
+        }
+
+        /** Alias for {@link build}. */
+        public async Lot<Model.DirectiveDto> to_lot() throws GLib.Error {
+            return yield build();
+        }
+
+        private enum ItemKind {
+            DIRECTIVE,
+            SLOT,
+            UPDATE
+        }
+
+        private class Item : Object {
+            public ItemKind kind;
+            public Model.DirectiveDto? directive;
+            public Slot? slot;
+            public State? update_state;
+            public string key;
+        }
+    }
+}

+ 12 - 4
src/EntrypointEndpoint.vala

@@ -32,18 +32,26 @@ namespace Statum {
                 return new HttpStringResult(@"No entrypoint bound to \"$path\"", StatusCode.NOT_FOUND);
             }
 
-            var held = resolver.resolve(http_context);
+            var resolved = resolver.resolve(http_context);
+
+            // If the client referenced slots the server no longer has cached, ask
+            // it to re-send those frames (then retry) before running the handler.
+            if (resolved.transmit_keys.to_array().length > 0) {
+                return StatumDirectives.transmit_result(resolved.transmit_keys, resolved.invalid_signatures);
+            }
+
             var request = new StatumRequest() {
                 uri = path,
                 route_params = ((!)match).route_params,
-                held = held,
+                held = resolved.held,
                 query = http_context.request.query_params
             };
             scope.register_local_scoped<StatumRequest>(() => request);
 
             var entrypoint = (StatumEntrypoint) scope.resolve_type(((!)match).entrypoint_type);
-            var directives = yield entrypoint.handle();
-            return StatumDirectives.to_result(directives);
+            var builder = yield entrypoint.handle();
+            var directives = yield builder.build();
+            return StatumDirectives.to_result(directives, resolved.invalid_signatures);
         }
 
         private static string extract_path(string uri) {

+ 106 - 0
src/GObjectMapping.vala

@@ -0,0 +1,106 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Statum {
+
+    /**
+     * Maps between GObject instances and {@link Properties} using GObject
+     * property introspection, so an application can declare a strongly-typed
+     * private-data model as an ordinary GObject property class (e.g.
+     * `public string order_id { get; set; }`) and have the framework seal/read it
+     * with no per-model mapper.
+     *
+     * Reuses the same introspection pattern as Invercargill's
+     * `ObjectPropertyAccessor` (and {@link Element.to_value}/{@link ValueElement}
+     * for the value conversions).
+     */
+    public class GObjectMapping : Object {
+
+        /**
+         * Maps a GObject instance to a {@link Properties} object by reading its
+         * declared (readable) GObject properties.
+         */
+        public static Properties to_properties(Object model) throws GLib.Error {
+            var dict = new PropertyDictionary();
+            var object_class = (ObjectClass) model.get_type().class_ref();
+            foreach (var spec in object_class.list_properties()) {
+                if ((spec.flags & ParamFlags.READABLE) == 0) {
+                    continue;
+                }
+                var value = Value(spec.value_type);
+                model.get_property(spec.name, ref value);
+                dict[spec.name] = new ValueElement(value);
+            }
+            return dict;
+        }
+
+        /**
+         * Builds a new instance of `type` and populates its (writable) GObject
+         * properties from `props`. Property names absent from `props` are left at
+         * their default.
+         */
+        public static Object from_properties(Type type, Properties props) throws GLib.Error {
+            var model = Object.new(type);
+            var object_class = (ObjectClass) type.class_ref();
+            foreach (var spec in object_class.list_properties()) {
+                if ((spec.flags & ParamFlags.WRITABLE) == 0) {
+                    continue;
+                }
+                Element element;
+                if (!props.try_get(spec.name, out element) || element == null) {
+                    continue;
+                }
+                try {
+                    var value = ((!)element).to_value(spec.value_type);
+                    model.set_property(spec.name, value);
+                } catch (GLib.Error e) {
+                    // Skip properties whose value cannot be coerced.
+                }
+            }
+            return model;
+        }
+
+        /**
+         * Convenience: builds and populates `type`, returning it cast to `T`.
+         */
+        public static T from_properties_typed<T>(Type type, Properties props) throws GLib.Error {
+            return (T) from_properties(type, props);
+        }
+
+        // ------------------------------------------------------------------
+        // Typed {@link Properties} getters (B4) — remove ad-hoc read boilerplate.
+        // ------------------------------------------------------------------
+
+        public static string? get_string(Properties props, string key) {
+            Element element;
+            if (props.try_get(key, out element) && element != null) {
+                return ((!)element).as_string_or_null();
+            }
+            return null;
+        }
+
+        public static int? get_int(Properties props, string key) {
+            Element element;
+            if (props.try_get(key, out element) && element != null) {
+                return ((!)element).as_int_or_null();
+            }
+            return null;
+        }
+
+        public static bool? get_bool(Properties props, string key) {
+            Element element;
+            if (props.try_get(key, out element) && element != null) {
+                return ((!)element).as_bool_or_null();
+            }
+            return null;
+        }
+
+        public static double? get_double(Properties props, string key) {
+            Element element;
+            if (props.try_get(key, out element) && element != null) {
+                return ((!)element).as_double_or_null();
+            }
+            return null;
+        }
+    }
+}

+ 100 - 14
src/HeldSlotResolver.vala

@@ -34,28 +34,90 @@ namespace Statum {
         private const string SLOT_HEADER = "X-Statum-Slot";
 
         /**
-         * Resolves every `X-Statum-Slot` header on `http_context` into a
-         * type-name → {@link HeldSlot} map.
+         * Resolves every `X-Statum-Slot` header on `http_context`.
+         *
+         *  - **Reference form** (`key=…; as_at=…`) for a slot still cached
+         *    server-side → resolved into {@link ResolvedSlots.held}.
+         *  - **Reference form** for a slot no longer in memory → its key is added
+         *    to {@link ResolvedSlots.transmit_keys}; the caller emits a
+         *    `transmit(retry)` so the client re-sends the frame (the slot post
+         *    endpoint re-verifies and re-caches it).
+         *  - **Base64 frame form** that verifies → resolved into
+         *    {@link ResolvedSlots.held}.
+         *  - **Base64 frame form** that fails verification (or is expired) → its
+         *    frame `signature` (read off the envelope, without trusting
+         *    `content`) is added to {@link ResolvedSlots.invalid_signatures}; the
+         *    caller emits an `invalidate`.
+         *
+         * Browsers coalesce multiple `X-Statum-Slot` headers into a single
+         * comma-separated header value; neither form contains a comma, so each
+         * header value is additionally split on `,`.
          */
-        public ReadOnlyAssociative<string, HeldSlot> resolve(HttpContext http_context) {
-            var held = new Dictionary<string, HeldSlot>();
+        public ResolvedSlots resolve(HttpContext http_context) {
+            var result = new ResolvedSlots();
 
             foreach (var header_value in http_context.request.headers.get_or_empty(SLOT_HEADER)) {
-                HeldSlot? slot = null;
-                if (header_value.contains("key=")) {
-                    slot = resolve_reference(header_value);
-                } else {
-                    slot = resolve_frame(header_value);
+                foreach (var part in header_value.split(",")) {
+                    var slot_value = part.strip();
+                    if (slot_value.length == 0) {
+                        continue;
+                    }
+
+                    HeldSlot? slot = null;
+                    if (slot_value.contains("key=")) {
+                        slot = resolve_reference(slot_value);
+                        if (slot == null || ((!)slot).type_name == null) {
+                            var key = parse_reference_key(slot_value);
+                            if (key != null) {
+                                result.transmit_keys.add((!)key);
+                            }
+                            continue;
+                        }
+                    } else {
+                        slot = resolve_frame(slot_value);
+                        if (slot == null || ((!)slot).type_name == null) {
+                            var signature = parse_frame_signature(slot_value);
+                            if (signature != null) {
+                                result.invalid_signatures.add((!)signature);
+                            }
+                            continue;
+                        }
+                    }
+
+                    result.held.set(((!)slot).type_name, (!)slot);
                 }
+            }
 
-                if (slot == null || ((!)slot).type_name == null) {
-                    continue;
-                }
+            return result;
+        }
 
-                held.set(((!)slot).type_name, (!)slot);
+        /** Extracts the `key` from a `key=…; as_at=…` reference header value. */
+        private static string? parse_reference_key(string slot_value) {
+            foreach (var part in slot_value.split(";")) {
+                var trimmed = part.strip();
+                if (trimmed.has_prefix("key=")) {
+                    var key = trimmed.substring(4).strip();
+                    return key.length > 0 ? key : null;
+                }
             }
+            return null;
+        }
 
-            return held;
+        /**
+         * Reads the `signature` off a base64 frame envelope WITHOUT trusting or
+         * parsing its `content`, so an untrustworthy/corrupt frame can still be
+         * reported for invalidation. Returns `null` if the envelope itself
+         * cannot be parsed.
+         */
+        private static string? parse_frame_signature(string slot_value) {
+            try {
+                var bytes = Base64.decode(slot_value);
+                var frame_json = to_null_terminated_string(bytes);
+                var signature = new JsonElement.from_string(frame_json).as<JsonObject>().get_string("signature");
+                return (signature != null && ((!)signature).length > 0) ? signature : null;
+            } catch (GLib.Error e) {
+                return null;
+            }
         }
 
         private HeldSlot? resolve_reference(string header_value) {
@@ -164,4 +226,28 @@ namespace Statum {
 
     }
 
+    /**
+     * The outcome of resolving a request's `X-Statum-Slot` headers.
+     */
+    public class ResolvedSlots : Object {
+
+        /** Successfully resolved held slots, indexed by type name. */
+        public Dictionary<string, HeldSlot> held { get; private set; default = new Dictionary<string, HeldSlot>(); }
+
+        /**
+         * Keys of reference-form slots no longer in the server's memory; the
+         * caller emits a `transmit(retry)` for each so the client re-sends the
+         * frame for the server to re-cache.
+         */
+        public Series<string> transmit_keys { get; private set; default = new Series<string>(); }
+
+        /**
+         * Signatures of frame-form slots that failed verification (or expired);
+         * the caller emits an `invalidate` for each so the client drops the
+         * matching held slot.
+         */
+        public Series<string> invalid_signatures { get; private set; default = new Series<string>(); }
+
+    }
+
 }

+ 11 - 0
src/Model/ActionDto.vala

@@ -1,5 +1,6 @@
 using Invercargill;
 using Invercargill.Mapping;
+using InvercargillJson;
 
 namespace Statum.Model {
 
@@ -27,6 +28,16 @@ namespace Statum.Model {
          */
         public string? @private { get; set; }
 
+        /**
+         * Serialises this action to a {@link JsonElement} so it can be embedded
+         * as a value in a snapshot's public data (e.g.
+         * `state.public_data["checkout"] = ref.to_element()`), where the client
+         * resolves it via `stm-action` and echoes `private` back.
+         */
+        public JsonElement to_element() throws GLib.Error {
+            return new JsonElement.from_properties(get_mapper().map_from(this));
+        }
+
         /**
          * The {@link PropertyMapper} used to (de)serialise an {@link ActionDto}.
          */

+ 42 - 0
src/Model/DirectiveDto.vala

@@ -69,6 +69,7 @@ namespace Statum.Model {
                 case "unsubscribe": return UnsubscribeDirectiveDto.get_mapper().materialise(json);
                 case "notify": return NotifyDirectiveDto.get_mapper().materialise(json);
                 case "transmit": return TransmitDirectiveDto.get_mapper().materialise(json);
+                case "invalidate": return InvalidateDirectiveDto.get_mapper().materialise(json);
                 default:
                     throw new ModelError.UNKNOWN_DIRECTIVE(@"Unrecognised directive type \"$((!)type)\"");
             }
@@ -405,4 +406,45 @@ namespace Statum.Model {
 
     }
 
+    /**
+     * Tells the frontend that a frame it sent could not be trusted by the
+     * server (its signature did not verify, or the snapshot it wraps has passed
+     * its {@link SnapshotDto.invalid_after} window).
+     *
+     * Rather than `key`, `invalidate` carries the offending frame's
+     * `signature`: the server reads the signature straight off the frame
+     * envelope without trusting or parsing its (potentially corrupt) `content`.
+     * The client correlates the signature with the held slot whose frame carries
+     * it and clears that slot — exactly as a `clear` directive would — so the
+     * client stops re-sending a frame the server cannot accept. It is emitted
+     * automatically by the framework (the slot post endpoint for frames whose
+     * signature fails verification, and the entrypoint/action endpoints for
+     * always-send frames that fail verification).
+     */
+    public class InvalidateDirectiveDto : DirectiveDto {
+
+        /** The signature of the frame the server could not trust. */
+        public string signature { get; set; }
+
+        public override string discriminator { get { return "invalidate"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return InvalidateDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public InvalidateDirectiveDto() { }
+
+        public InvalidateDirectiveDto.with_signature(string signature) {
+            this.signature = signature;
+        }
+
+        public static PropertyMapper<InvalidateDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<InvalidateDirectiveDto>(cfg => {
+                cfg.map<string>("signature", o => o.signature, (o, v) => o.signature = v);
+                cfg.set_constructor(() => new InvalidateDirectiveDto());
+            });
+        }
+
+    }
+
 }

+ 16 - 8
src/SlotPostEndpoint.vala

@@ -7,14 +7,16 @@ using Astralis;
 namespace Statum {
 
     /**
-     * `POST /_statum/slots` — refreshes snapshots that have passed their
-     * `transmit_after` window.
+     * `POST /_statum/slots` — refreshes snapshots whose `transmit_after` window
+     * has elapsed, and is also the target of a `transmit(retry)` directive.
      *
-     * The client POSTs a JSON array of Statum frames it currently holds whose
-     * cache has expired. For each frame this endpoint verifies the signature,
-     * {@link StateService.restore_slot}s it into the cache, re-signs it (issuing
-     * a fresh `as_at`/`transmit_after`), and returns a `set` directive per frame
-     * so the client refreshes its local snapshot (mirroring `server.php`).
+     * The client POSTs a JSON array of Statum frames it holds. For each frame
+     * this endpoint verifies the signature: on success it
+     * {@link StateService.restore_slot}s the slot into the cache, re-signs it
+     * (issuing a fresh `as_at`/`transmit_after`), and returns a `set` directive
+     * so the client refreshes its local snapshot; on failure it returns an
+     * `invalidate` for that frame's signature so the client drops the slot whose
+     * frame the server could not trust.
      */
     public class SlotPostEndpoint : Object, Endpoint {
 
@@ -26,6 +28,7 @@ namespace Statum {
             var body_str = body.to_raw_string();
 
             var directives = new Series<Model.DirectiveDto>();
+            var invalid_signatures = new Series<string>();
 
             if (body_str.strip().length > 0) {
                 var array = new JsonElement.from_string(body_str).as<JsonArray>();
@@ -35,6 +38,11 @@ namespace Statum {
                     try {
                         signing_provider.verify_frame(frame);
                     } catch (GLib.Error e) {
+                        // Signature did not verify: tell the client to drop the
+                        // slot holding this frame, identified by signature.
+                        if (frame.signature != null && frame.signature.length > 0) {
+                            invalid_signatures.add(frame.signature);
+                        }
                         continue;
                     }
 
@@ -50,7 +58,7 @@ namespace Statum {
                 }
             }
 
-            return StatumDirectives.to_result(directives);
+            return StatumDirectives.to_result(directives, invalid_signatures);
         }
 
     }

+ 17 - 6
src/Statum.vala

@@ -27,6 +27,7 @@ namespace Statum {
             container.register_singleton<StateService>();
             container.register_singleton<HeldSlotResolver>();
             container.register_singleton<EntrypointRouteTable>();
+            container.register_singleton<ActionRegistry>();
 
             // The realtime channel endpoint is the singleton ChannelService.
             container.register_singleton<ChannelEndpoint>()
@@ -46,6 +47,14 @@ namespace Statum {
                 .as<Endpoint>()
                 .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/slots", Method.POST));
 
+            // The single action endpoint resolves /_statum/action/{guid} to the
+            // action type via ActionRegistry. Registered for all common verbs so
+            // an action may be authored with whichever method suits it.
+            container.register_scoped<ActionEndpoint>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/action/{guid}",
+                    Method.GET, Method.POST, Method.PUT, Method.PATCH, Method.DELETE));
+
             container.register_scoped<ResourceEndpoint>()
                 .as<Endpoint>()
                 .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/resource/{name}"));
@@ -81,6 +90,7 @@ namespace Statum {
 
         private Container container = inject<Container>();
         private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
+        private ActionRegistry action_registry = inject<ActionRegistry>();
 
         /**
          * Registers a static page (no entrypoint) served at `route`.
@@ -112,13 +122,14 @@ namespace Statum {
         }
 
         /**
-         * Registers a {@link StatumAction} (a directive-returning POST/GET
-         * endpoint) at `route`.
+         * Registers a {@link StatumAction}, auto-assigning it a GUID endpoint at
+         * `/_statum/action/{guid}` (Spry-style — no hand-picked URI). Invoke the
+         * action from the client by embedding an authored reference in state (see
+         * {@link ActionRegistry.author}) and binding it with `stm-action`.
          */
-        public void add_action<TAction>(EndpointRoute route) {
-            container.register_scoped<TAction>()
-                .as<Endpoint>()
-                .with_metadata<EndpointRoute>(route);
+        public void action<TAction>() {
+            container.register_scoped<TAction>();
+            action_registry.register<TAction>();
         }
 
     }

+ 30 - 2
src/StatumDirectives.vala

@@ -12,13 +12,41 @@ namespace Statum {
         /** The content type that distinguishes a Statum directive response. */
         public const string CONTENT_TYPE = "application/vnd.statum+json";
 
+        /**
+         * Builds the short-circuit response an endpoint returns when the request
+         * carried slots the server does not have in memory: a `transmit(retry)`
+         * per missing key (plus an `invalidate` per untrustworthy frame
+         * signature). The client re-sends the frames to the slot post endpoint,
+         * drops any that fail verification, then re-issues the original request.
+         */
+        public static HttpResult transmit_result(Lot<string> transmit_keys, Lot<string>? invalid_signatures = null) throws GLib.Error {
+            var directives = new Series<Model.DirectiveDto>();
+            foreach (var key in transmit_keys) {
+                directives.add(new Model.TransmitDirectiveDto.with(key, true));
+            }
+            return to_result(directives, invalid_signatures);
+        }
+
         /**
          * Renders a lot of directives as the JSON array response served by every
          * directive-returning endpoint, with the Statum content type and
          * `Cache-Control: no-store`.
+         *
+         * Any invalid frame `signature`s are appended as `invalidate` directives
+         * so the client drops slots whose frames the server could not trust.
          */
-        public static HttpResult to_result(Lot<Model.DirectiveDto> directives) throws GLib.Error {
-            var json = Model.DirectiveDto.serialize_array(directives);
+        public static HttpResult to_result(Lot<Model.DirectiveDto> directives, Lot<string>? invalid_signatures = null) throws GLib.Error {
+            var rendered = new Series<Model.DirectiveDto>();
+            foreach (var directive in directives) {
+                rendered.add(directive);
+            }
+            if (invalid_signatures != null) {
+                foreach (var signature in (!)invalid_signatures) {
+                    rendered.add(new Model.InvalidateDirectiveDto.with_signature(signature));
+                }
+            }
+
+            var json = Model.DirectiveDto.serialize_array(rendered);
             var result = new HttpStringResult(json.stringify());
             result.set_header("Content-Type", CONTENT_TYPE);
             result.set_header("Cache-Control", "no-store");

+ 96 - 26
src/StatumHandlers.vala

@@ -10,12 +10,14 @@ namespace Statum {
      *
      * After a page is served its HTML boots the Statum JS, which GETs
      * `/_statum/entrypoint?uri=…`. The framework resolves the entrypoint bound
-     * to that URI (see {@link StatumConfigurator.add_page}) and calls
-     * {@link handle}, whose returned directives hydrate the page.
+     * to that URI (see {@link StatumConfigurator.add_entrypoint_page}) and calls
+     * {@link handle}, whose returned {@link DirectiveBuilder} directives hydrate
+     * the page.
      *
-     * Subclasses are given the {@link request} (URI, route params, held slots,
-     * query), the {@link state_service} and the {@link channel_service}; they
-     * return the directives that set/subscribe/etc. the template's slots.
+     * Subclasses start a builder via {@link directives}, chain directives onto
+     * it, and return it. They are given the {@link request}, {@link state_service},
+     * {@link channel_service} and {@link action_registry} (for authoring action
+     * references to embed in state updates).
      */
     public abstract class StatumEntrypoint : Object {
 
@@ -28,49 +30,78 @@ namespace Statum {
         /** The singleton realtime channel service. */
         protected ChannelService channel_service = inject<ChannelService>();
 
+        /** The singleton action registry (for authoring action references). */
+        protected ActionRegistry action_registry = inject<ActionRegistry>();
+
         /**
-         * Returns the directives that hydrate the page bound to this entrypoint.
+         * Returns the directive builder that hydrates the page bound to this
+         * entrypoint. The framework calls {@link DirectiveBuilder.build} on it.
          */
-        public abstract async Lot<Model.DirectiveDto> handle() throws GLib.Error;
+        public abstract async DirectiveBuilder handle() throws GLib.Error;
 
+        /** Starts a fluent {@link DirectiveBuilder} bound to the state service. */
+        protected DirectiveBuilder directives() {
+            return new DirectiveBuilder(state_service);
+        }
     }
 
     /**
      * Base class for Statum action handlers.
      *
-     * Actions are app-defined URIs. A {@link StatumAction} subclass implements
-     * the {@link Endpoint} interface directly: the framework routes a request to
-     * it, this base resolves the held slots, decrypts the `X-Statum-Private`
-     * header into {@link StatumRequest.action_private}, parses the form body into
-     * {@link StatumRequest.form}, builds the {@link request}, and then invokes
-     * {@link handle}. The returned directives are rendered as the response.
+     * Actions are auto-mapped to GUID endpoints (`/_statum/action/{guid}`) at
+     * registration. The framework routes a request to the matching action, this
+     * base resolves the held slots, decrypts the `X-Statum-Private` header into
+     * {@link StatumRequest.action_private} (under {@link private_namespace}),
+     * parses the form body, builds the {@link request}, then invokes
+     * {@link handle} and renders the builder's directives.
+     *
+     * For typed private data, extend {@link TypedStatumAction} instead.
      */
     public abstract class StatumAction : Object, Endpoint {
 
-        /** Namespace mixed into action-private blobs (see `model.md`). */
+        /** Namespace used by the non-generic base (untyped action-private). */
         public const string ACTION_PRIVATE_NAMESPACE = "action-private";
 
         protected HeldSlotResolver resolver = inject<HeldSlotResolver>();
         protected Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
         protected StateService state_service = inject<StateService>();
         protected ChannelService channel_service = inject<ChannelService>();
+        protected ActionRegistry action_registry = inject<ActionRegistry>();
         protected Inversion.Scope scope = inject<Inversion.Scope>();
 
         /** The per-request Statum context (populated before {@link handle}). */
         protected StatumRequest request;
 
         /**
-         * Returns the directives produced by the action (e.g. a refreshed `set`
-         * for the slot it mutated, plus any `notify`/`navigate`).
+         * Namespace under which this action's private blobs are sealed/read.
+         * Overridden by {@link StatumAction<TPrivate>} to the action's type name.
+         */
+        protected virtual string private_namespace { get { return ACTION_PRIVATE_NAMESPACE; } }
+
+        /**
+         * Returns the directive builder for this action's response (e.g. a
+         * refreshed `set` for a mutated slot, plus any `notify`/`navigate`).
          */
-        public abstract async Lot<Model.DirectiveDto> handle() throws GLib.Error;
+        public abstract async DirectiveBuilder handle() throws GLib.Error;
+
+        /** Starts a fluent {@link DirectiveBuilder} bound to the state service. */
+        protected DirectiveBuilder directives() {
+            return new DirectiveBuilder(state_service);
+        }
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
-            var held = resolver.resolve(http_context);
-            var action_private = decrypt_action_private(http_context);
+            var resolved = resolver.resolve(http_context);
+
+            // If the client referenced slots the server no longer has cached, ask
+            // it to re-send those frames (then retry) before processing the action.
+            if (resolved.transmit_keys.to_array().length > 0) {
+                return StatumDirectives.transmit_result(resolved.transmit_keys, resolved.invalid_signatures);
+            }
 
             FormData? form = null;
-            if (http_context.request.content_type != "" && (
+            var content_length_header = http_context.request.get_header("Content-Length");
+            var content_length = content_length_header != null ? int.parse((!)content_length_header) : 0;
+            if (content_length > 0 && (
                     http_context.request.is_form_urlencoded() || http_context.request.is_multipart())) {
                 form = yield FormDataParser.parse(http_context.request.request_body, http_context.request.content_type);
             }
@@ -78,15 +109,16 @@ namespace Statum {
             request = new StatumRequest() {
                 uri = route_context.requested_path,
                 route_params = route_context.mapped_parameters,
-                held = held,
+                held = resolved.held,
                 query = http_context.request.query_params,
-                action_private = action_private,
+                action_private = decrypt_action_private(http_context),
                 form = form
             };
             scope.register_local_scoped<StatumRequest>(() => request);
 
-            var directives = yield handle();
-            return StatumDirectives.to_result(directives);
+            var builder = yield handle();
+            var directives = yield builder.build();
+            return StatumDirectives.to_result(directives, resolved.invalid_signatures);
         }
 
         private Properties decrypt_action_private(HttpContext http_context) {
@@ -95,12 +127,50 @@ namespace Statum {
                 return new PropertyDictionary();
             }
             try {
-                return encryption_provider.read_properties(ACTION_PRIVATE_NAMESPACE, (!)blob);
+                return encryption_provider.read_properties(private_namespace, (!)blob);
             } catch (GLib.Error e) {
                 return new PropertyDictionary();
             }
         }
-
     }
 
+    /**
+     * Base class for Statum action handlers that carry strongly-typed private
+     * data.
+     *
+     * `TPrivate` is an ordinary GObject property class (e.g.
+     * `public string order_id { get; set; }`); the framework maps it to/from the
+     * sealed blob via GObject introspection ({@link GObjectMapping}). The seal
+     * namespace is the action's type name, so one action type cannot decrypt
+     * another's private blob.
+     *
+     * Read the decrypted, typed private data via {@link request_private}.
+     *
+     * (Named `TypedStatumAction` rather than `StatumAction<TPrivate>` because
+     * Vala does not permit a generic and non-generic class to share a name.)
+     */
+    public abstract class TypedStatumAction<TPrivate> : StatumAction {
+
+        private TPrivate? _private_data = null;
+        private bool _private_resolved = false;
+
+        /** The decrypted, typed private data for this action, or `null`. */
+        protected TPrivate? request_private {
+            get {
+                if (!_private_resolved) {
+                    _private_resolved = true;
+                    try {
+                        _private_data = (TPrivate) GObjectMapping.from_properties(typeof(TPrivate), request.action_private);
+                    } catch (GLib.Error e) {
+                        _private_data = null;
+                    }
+                }
+                return _private_data;
+            }
+        }
+
+        protected override string private_namespace {
+            get { return this.get_type().name(); }
+        }
+    }
 }

+ 1 - 1
src/StatumPage.vala

@@ -75,7 +75,7 @@ namespace Statum {
         public abstract string get_etag_for(int variant, string encoding);
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
-            var held = resolver.resolve(http_context);
+            var held = resolver.resolve(http_context).held;
 
             StatumVariantSelection selection;
             try {

+ 4 - 0
src/meson.build

@@ -42,6 +42,10 @@ sources = files(
     'SlotPostEndpoint.vala',
     'EntrypointEndpoint.vala',
     'ResourceEndpoint.vala',
+    'ActionRegistry.vala',
+    'ActionEndpoint.vala',
+    'DirectiveBuilder.vala',
+    'GObjectMapping.vala',
     'Model/Scope.vala',
     'Model/SlotDto.vala',
     'Model/FrameDto.vala',