Action Lifecycle

Every action token in Cloudillo enters the system through one of two doors: a local user creates one (POST /api/actions), or a peer instance delivers one (POST /api/inbox). The two doors run different checks — one signs, the other verifies — but they meet in the same room. Both end in process_after_store(), which is where hooks fire, WebSocket clients are notified, subscribers are fanned out to, and federated delivery is scheduled.

Understanding that convergence is most of what you need. The rest is which checks guard which door.

flowchart TB
    subgraph outbound[Outbound]
        A1[POST /api/actions] --> A2[create_action:<br/>validate, resolve visibility,<br/>store pending row]
        A2 --> A3["return @a_id placeholder"]
        A2 -.schedules.-> A4[ActionCreatorTask:<br/>resolve deps, sign,<br/>finalize row]
    end
    subgraph inbound[Inbound]
        B1[POST /api/inbox] --> B2[verify signature,<br/>validate, check permissions]
        B2 --> B3[resolve + clamp visibility,<br/>store row as transient]
    end
    A4 --> C[process_after_store]
    B3 --> C
    C --> D[hooks → WebSocket →<br/>fan-out → delivery]
    D --> E[inbound only:<br/>authoritative status write,<br/>deferred WS forward]

Creating an Action

Action creation is two-phase, and the split matters to anyone writing a client. create_action() (crates/cloudillo-action/src/task.rs:49) validates and stores a pending row, then returns a placeholder immediately — @{a_id}, a local database id, not an action ID. The token does not exist yet. Signing happens later, in a scheduled ActionCreatorTask, once the action’s dependencies have resolved.

That is why an attachment upload and its post can be submitted together: the post’s task simply depends on the file’s task and waits.

Phase 1 — validate and park

Step Operation Notes
1 Identity check The audience, if present, must be a well-formed identity tag
2 Ephemeral short-circuit Types with ephemeral: true never persist; they take a separate path and return
3 Unknown-recipient check Unless the type sets allow_unknown, the audience must be someone you follow or are connected to
4 Subscription check Types with requires_subscription need a SUBS:{target}:{issuer} row (or one on the target’s root) — unless you are the target’s creator
5 Flag gating gated_by_parent_flag / gated_by_subject_flag reject the action when the parent or subject has that capability disabled (comments off, reactions off)
6 Visibility resolution Explicit → parent → subject → type default → profile.default_visibility → F; then the open-flag promotion. See Visibility
7 REPOST canonicalization Reposts require an explicit audience, refuse self-reposts and non-public subjects, and flatten a repost-of-a-repost down to the true original before signing
8 DSL validation Field constraints and content schema, run against the final canonicalized field set — the exact shape that will be signed
9 Store pending row action_id is empty; the dedup key is computed from the type’s key_pattern
10 Schedule An ActionCreatorTask is created, depending on any unresolved attachment or subject tasks

Drafts leave the pipeline at step 9: a plain draft is stored with status R, one carrying publish_at with status S plus a DraftPublishTask scheduled for that time. Neither gets a dedup key — that is generated at publish.

Note

Validation runs twice in the system: here, on the create path, and again on the receiving side in process.rs. A locally created action that violates its own DSL schema is rejected with a 4xx here, rather than silently by every peer that receives it.

Phase 2 — resolve, sign, finalize

ActionCreatorTask::run() (task.rs:508) runs once the scheduler’s dependencies are satisfied:

  1. Resolve attachments — @ placeholders become real file IDs.
  2. Upgrade attachment visibility to match the action’s, so a public post’s images are not left private.
  3. Resolve the subject — @{a_id} becomes the sibling’s real a1~ action ID.
  4. Resolve the audience from the parent, when none was given. A reply follows its parent’s home, which is what lets a message in a community-hosted conversation federate to the community. Skipped for broadcast types such as STAT, where an absent audience is itself the signal.
  5. Regenerate the dedup key if the subject changed during resolution.
  6. Sign — build the claims, fetch the tenant’s private key, sign ES384, and derive the action ID from the token. See Token Format.
  7. Finalize the stored row with the token, ID, resolved subject and audience.
  8. process_after_store() — the shared tail below.

Receiving an Action

Inbound processing lives in process_inbound_action_token_inner() (crates/cloudillo-action/src/process.rs:283).

Step Operation Notes
1 Decode unverified Just enough to see the type; a CONN action must clear proof-of-work first
2 Verify signature Always, no exceptions. The issuer’s public key is fetched and cached
3 Resolve definition Full type first (IDP:REG), then the base type (IDP)
4 DSL validation Field constraints and content schema — this runs for pre-approved related tokens too, so a malformed token smuggled inside a bundle is still rejected
5 Permission checks Relationship, subscription, and flag-based checks. All three are skipped for pre-approved related actions
6 Ephemeral short-circuit Ephemeral types are forwarded to WebSocket clients and never stored; processing ends here
7 Visibility Inherited from parent or subject, defaulted, then clamped by the recipient’s profile.visibility_cap — a peer cannot make content more visible on your instance than your own setting allows. See Visibility. Skipped for pre-approved actions
8 Store If the row already exists, processing stops — this is what makes redelivery idempotent
9 Sync attachments Fetched from the issuer’s instance (async path only)
10 process_after_store() The shared tail below
11 Authoritative status write One write, after everything above succeeded
12 Deferred WebSocket forward The push the shared tail held back

Steps 11 and 12 are the part worth reading twice.

While the pipeline runs, the row sits at a transient status (P or V). Any failure returns an error and leaves it there, so the verifier task can retry the whole thing later. Only when attachment sync and post-store processing have both succeeded does the final status get written — once — from whatever the on_receive hook declared, defaulting to A — see Action Status Codes. That single write is why a hook can end an action at C (confirmation pending) or D (rejected) without a later step clobbering it, and the V → A transition is the moment a federated action becomes searchable.

The WebSocket forward is deferred for the same reason. If it fired inside process_after_store(), connected clients would see the transient V — which the shell’s feed filter drops — and the notification would silently vanish. Outbound rows are already final at that point, so they forward inline; inbound rows wait.

The Shared Tail: process_after_store

post_store::process_after_store() (crates/cloudillo-action/src/post_store.rs:108) is where both directions land. It takes a ProcessingContext telling it which door the action came through, and everything downstream is the same code.

  1. Execute the hook — on_create for outbound, on_receive for inbound. The hook’s declared resting status is captured before the early-return check, so a hook that aborts the rest of the pipeline still gets its status honoured.
  2. Forward to WebSocket clients — inline for outbound, deferred to the caller for inbound.
  3. Fan out to subscribers. When the action is an accepted child of a relay_children container this instance owns, the container’s owner mints an APRV vouching for it, and that APRV’s fan-out federates the child — so the raw fan-out is suppressed to avoid sending it twice. A child the hook rejected is never vouched.
  4. Schedule delivery to the computed recipients, with a broadcast check for actions posted to one’s own wall.
  5. Auto-approve (inbound, async only) approvable actions from trusted sources.
  6. Sync involved profiles (inbound) — issuer, audience, and an identity-typed subject, so names and avatars resolve instead of rendering as “Unknown”.

Steps 3 and 4 are where federation actually happens; both relay mechanisms are described in Trust Flows.

Native Hooks

Hooks are the per-type behaviour attached to lifecycle events. They are implemented in native Rust rather than the DSL, and registered at startup in crates/cloudillo-action/src/native_hooks/mod.rs.

Action Type on_create on_receive on_accept on_reject
CONN ✓ ✓ ✓ ✓
FLLW ✓ ✓ - -
FSHR ✓ ✓ ✓ -
REACT ✓ ✓ - -
REPOST ✓ ✓ - -
CMNT ✓ ✓ - -
MSG ✓ ✓ - -
SUBS ✓ ✓ - -
CONV ✓ ✓ - -
INVT ✓ ✓ ✓ -
APRV - ✓ - -
STAT - ✓ - -
IDP:REG - ✓ - -
PRINVT - ✓ - -
  • on_create — the local user created this action.
  • on_receive — a federated action of this type arrived at /api/inbox.
  • on_accept — the user accepted a confirmation action (CONN, FSHR, INVT).
  • on_reject — the user rejected one (CONN only).

MSG has both create and receive hooks, and they earn their place: a group message whose parent is a CONV maintains that conversation’s comment-style counters, reusing the same machinery CMNT uses, and emits a STAT so every subscriber’s counters converge.

Counter-maintaining hooks only run on the instance that owns the subject. For a post with an audience — a community-hosted post — the audience is the owner; otherwise the issuer is. The two branches are disjoint per subject, so the owning instance is the only writer.

Info

POST, PRES, and APKG action types have no native hooks — they use the default processing pipeline.

There Is No Acknowledgement Token

Cloudillo has no ACK action type, and no step in either pipeline generates one. Three separate mechanisms cover what an acknowledgement would otherwise be asked to do:

Delivery reliability is the scheduler’s job. ActionDeliveryTask failures are retried under the scheduler’s retry policy; the sender does not wait for the receiver to confirm.

Read state is PUT /api/read-marker. It is reader-local and deliberately never federated — nobody else learns what you have read.

Counter convergence is STAT. Its payload is {r, c, ct, rp} — a reactions string, comment/message count, last-comment timestamp, and repost count — and it is always emitted in full, including zeros, so a receiver can decrement as well as increment. STAT emission is coalesced on a quiet window (federation.stat_coalesce_window_secs, 10 seconds by default), with a hard ceiling (federation.stat_coalesce_max_window_secs, 60 seconds) so a continuously busy post still broadcasts rather than deferring forever.

See Also

  • Token Format - Field reference, content addressing, visibility levels, status codes
  • Action DSL - How action type definitions declare behavior flags and hooks
  • Federation - Delivery, trust flows, and inter-instance protocols
  • Task Scheduler - Dependency resolution and retry policy