inbox
v0.1.49
KickSide inbox — central review/approval inbox with declarative, web-component-rendered item types and realtime updates.
Run
wippy run kickside/inboxkickside/inbox
Central review/approval inbox. Action results, reminders, and notices land here for human review.
- Items are thread events reduced by the owner inbox projection
(
pending -> approved | rejected | dismissed | resolved). Built-in actions use their specific status; other declared actions useresolved. There is no parallel count/log table; callers derive counts from the same projected item list. - Item types are declarative: a module registers a
registry.entrywithmeta.type = inbox.item_typecarrying render metadata (value,name,icon,category,component_tag,component_base_path, and optionalaction_owner: component). The UI maps an item'sitem_typeto the item type's web component — adding an item type needs no inbox change. The host owns pending action controls unless the declaration explicitly assigns them to its component. Item metadata cannot change that ownership. The bundlednoticeitem type is the base reference to copy. - Public meta: after Core commits the projection, its declared post-commit
worker writes component-wide renderable summary
counts to component meta using flat, explicit
<thing>_countfields (pending_count,item_count,resolved_count,urgent_count,high_count).pending_countis the actionable inbox badge. Do not store per-viewer unread/visited state in public meta. - Realtime: the post-commit worker writes public meta, and the component engine emits
component.<id>/component.meta.changed. The UI uses that canonical topic as its live invalidation signal and reloads the list from/inbox; there is no parallel inbox realtime topic. - Resolution consistency: the canonical
inbox.item_typedeclaration owns its allowedactions, optional side-effect-freeon_validate, and optional idempotenton_resolve. Inbox invokes both callbacks synchronously under the resolver's actor and scope, and appends the one terminalitem.resolvedevent only after they succeed. Item metadata is data, never executable binding authority. Because a callback may succeed immediately before an append fails, providers must bind the supplied stableresolution_idto exactly one action and form payload, make an identical retry idempotent, and reject a retry whose action or form differs. Inbox does not yet persist a terminal claim before invoking callbacks, so this provider contract is required. Pending items also resolve against the current live item-type declaration; declaration updates must remain backward-compatible with existing pending items until declaration version freezing is designed. - Workflow port:
kickside.inbox.sink:inbox_itemis the canonical writable destination. It is a first-classkickside.automation.portbacked bykickside.inbox.sink:inbox_sinkand posts to the ambient actor's inbox. Workflow builders discover it from the same ports catalog as every other sink; there is no separate inbox dropdown/catalog path.
Empty item metadata and submitted form values serialize as JSON objects
({}), using map-capacity Lua tables at storage and response boundaries.
API
| Method | Path | Purpose |
|---|---|---|
| GET | /inbox | list items (status, type, priority, page, limit) |
| GET | /inbox/item-types | declared item types + render metadata |
| POST | /inbox | enqueue an item |
| POST | /inbox/{id}/{action} | apply an action declared by the item's canonical item type |
Modules can also enqueue in-process via the inbox_writer library. Terminal
resolution always goes through inbox_resolve, which owns action validation and
callbacks before committing the event.
Aggregate notices
The writable sink accepts optional input.aggregate_key and input.source_id
(also available in destination config). source_id names a component. One item
belongs to the tuple (source_id, aggregate_key, ambient actor) across deliveries.
The actor needs live READ access to that component on every write and read;
revoked or deleted sources hide their aggregate items from Inbox responses.
Items without aggregate_key keep their existing identity and resolution behavior.
An upsert with status: pending (the default) refreshes the title, body, priority
and metadata and reopens the aggregate. An upsert with status: resolved
explicitly closes it without requiring content. Both use existing item.version
events; resolution returns the writable contract's updated receipt. Resolving
an absent aggregate does not create an item. Replaying a delivery key has no new
effect, including after a later reopen. Supply a new idempotency_key for each
new snapshot and retain it for retries.
For example, call kickside.inbox.sink:inbox_sink through
kickside.data:writable.write with:
{ config = {}, sink_op = "upsert", origin = "review-source",
idempotency_key = "review-snapshot-3",
input = { source_id = component_id, aggregate_key = "pending-review",
content = "3 files need review", metadata = { count = 3, link = "/review" } } }
When no work remains, send the same source and key with status = "resolved"
and a new delivery key. Inbox does not infer lifecycle from extension metadata.
In-process owners can use inbox_writer.create({ aggregate_key = ..., source_id = ..., item_type = ..., title = ..., ... }) and inbox_writer.resolve_aggregate({ aggregate_key = ..., source_id = ..., idempotency_key = ... }).
Source lifecycle resolution does not invoke human action callbacks. Human
actions still go through inbox_resolve; each refreshed aggregate version has
its own resolution identity so reopened work can receive a new decision.
Cross-user review delivery requires inbox.assign on user:<assignee>.
The application binds kickside.inbox.security:assignment_security_scope to its
administrator group. The default assigner group has no application members.
The review arm emits the declared Inbox output event as the sender, retains the
recipient's existing component ownership, and records metadata.assigned_by.
It never reconstructs the recipient's actor or reads the recipient's Inbox.
A recipient must have a provisioned Inbox. Re-arming uses the same event identity
and does not reopen a resolved decision.