Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
22 KiB
PRD — Workflow Data Store (KV + Entity Links)
- Slug:
workflow-data-store - Date:
2026-06-04 - Status: Draft
Summary
Add a tenant-scoped, cross-run persistent data store that workflows can read and write through registered actions. It ships two complementary primitives:
- Key/Value store (
workflow_data_store) — store any JSON value under a free-formnamespace+key. For counters, cursors, dedup flags, accumulators, run-to-run handoff, and small config. - Entity links (
workflow_entity_links) — a first-class, bidirectional, many-to-many mapping between two identifiers ({type, id}↔{type, id}) within anamespace. For mapping entities the system produces/consumes — e.g. mirroring a task in one project to a task (or tasks) in another.
The immediate driver is cross-project task mirroring ("update tasks in project B when tasks in project A change"), which requires durable state that survives a single run and is shared across runs. But the design is intentionally generic so it is reused across many automation scenarios.
This extends the existing V2 workflow action registry and persistence conventions; it introduces no new runtime framework.
Problem
A V2 workflow run carries state only in its Envelope (payload, vars, meta). That state is per-run and is discarded when the run ends. Snapshots (workflow_run_snapshots) are internal recovery only. There is no supported way for one run to leave a fact that a later, separate run can read.
Cross-project task mirroring needs exactly that: one run records task_A → task_B, and a different run (triggered later by an update to task_A) must look that mapping up. More broadly, authors repeatedly need to remember things across runs (last-processed cursor, "already handled" markers, external-id ↔ internal-id maps) and today have no primitive for it.
The one adjacent table, tenant_external_entity_mappings, is integration-specific (QBO/Xero), uses a textual tenant_id, is not Citus-distributed, and carries integration_type/external_realm_id semantics. Repurposing it for internal workflow mapping would overload a table that accounting sync depends on. We reuse the pattern, not the table.
Goals
- Provide a generic, tenant-scoped key/value store usable from any workflow via actions.
- Provide a generic, bidirectional, many-to-many entity-link/mapping store usable from any workflow via actions.
- Free-form namespaces (created on first write); no mandatory registration step.
- Support the cross-project task-mirroring scenario end-to-end with these primitives alone.
- Follow existing V2 conventions:
shared/workflow/persistencemodels,businessOperationsaction modules,withTenantTransaction+requirePermission+writeRunAudit+ idempotency. - Land as a single CE migration that creates the tables with
tenant uuidfrom the start and distributes them inline (Citus-guarded), colocated into the workflow colocation group (group 41, withworkflow_runs). - Expose the actions in the workflow designer under a "Data Store" palette group with expression/scope-picker support.
- All new designer UI uses AlgaPSA
@alga-psa/uidesign-system components (no native HTML controls) and is fully localized under themsp/workflowsi18n namespace, passingvalidate-translations.cjsacross all 9 locales.
Non-goals
- No managed namespace registry, declared value schemas, or per-namespace governance UI in this plan (free-form only; a
list_namespaceshelper gives autocomplete without a registry). - No per-action
persist/auto-capture field and no automatic "store every step output." Writes are always explicit, dedicated Data Store nodes — the designer is already complex, and adding a persist affordance to every action would add confusing surface everywhere. Storing is a deliberate act with its own node. - No strict 1:1 cardinality enforcement on links; cardinality is N:M by design (callers decide fan-out). Only duplicate identical edges are prevented.
- No N-ary links (only binary edges between two identifiers). Model richer relationships with
namespace+ edgeattributes. - No reuse or migration of
tenant_external_entity_mappings. - No changes to workflow runtime mapping semantics, Envelope shape, or persisted step shape.
- No general-purpose blob storage; values are bounded JSON (size-capped).
- No cross-tenant or global (tenantless) scope.
Users and Primary Flows
Users
- Workflow authors who need state that outlives a single run.
- MSP operators automating cross-project / cross-entity synchronization.
- Authors building dedup, rate-limit, counter, and "remember last seen" automations.
Primary flow — cross-project task mirror
- Link-setup workflow. Trigger
PROJECT_TASK_CREATEDin project A (gated to mirrored projects). Create the counterpart in B viaprojects.create_task, thenlinks.upsertrecords the edge{namespace:'project-task-mirror', from:{project_task, taskA}, to:{project_task, taskB}, attributes:{fieldMap}}. - Mirror workflow. Trigger
PROJECT_TASK_UPDATEDfortaskA.links.lookup{from:{project_task, taskA}, direction:'forward'}returnsmatches[]; acontrol.forEachover matches callsprojects.update_task(match.id, mappedFields). No matches → no-op. Reverse sync usesdirection:'reverse'.
Secondary flows
- Dedup:
store.set{namespace:'invoice-dunning-sent', key: invoiceId, value:true, if_revision:0}; CONFLICT means already sent → branch to skip. - Counter:
store.increment{namespace:'tenant-onboarding', key:'welcome-emails', by:1}. - Cursor:
store.get/store.set{namespace:'sync-cursor', key: sourceId, value: lastSeenIso}.
Design
Namespaces
A namespace is the author-chosen logical bucket that groups related entries within a tenant. It is a plain text column — not a Postgres schema or Citus concept — and is the first part of every entry's identity: a KV row is (tenant, namespace, key) and a link edge is (tenant, namespace, left, right, relation). Think of it as a collection / keyspace-prefix / bucket name.
Its job is to keep unrelated automations from colliding: without it, every key shares one flat per-tenant space and a key from one feature could clash with the same key from another. Namespaces also scope reads — store.list/links.lookup/*.list_namespaces operate within a single namespace. They are free-form and created on first write (no registration); the designer surfaces the field as a soft-enum combobox autocompleting from namespaces already in use.
Examples in one tenant: project-task-mirror (task↔task mappings), invoice-dunning-sent (dedup flags by invoice id), sync-cursor (last-seen timestamps by source id), onboarding-counters (counters). A single namespace can hold mappings for all project pairs at once, since each edge carries the actual entity ids; split into multiple namespaces only when you want separately listable/auditable buckets.
Write model & referencing (vars vs. store)
Writes are explicit, dedicated nodes (store.set, store.delete, store.increment, links.upsert, links.delete). The author adds a Data Store node only where they want to persist something specific; no other action gains any new field. This keeps the designer uncluttered and every write visible in the pipeline and run inspector.
What can be stored is whatever the author maps into the node's value (KV) or left/right/attributes (links): a literal string, number, or boolean; a JSON object/array; a prior step's output via { $expr: "vars.x" }; or an entire trigger/webhook payload via { $expr: "payload" } (stored whole as jsonb, bounded by the size cap). Link identifiers are arbitrary strings, mapped explicitly.
Referencing works the same in both scopes — the store is just durable rows keyed by (tenant, namespace, key):
- Same run —
store.get/links.lookupwithsaveAsloads the value into this run'svars; reference it via{ $expr: "vars.x" }. Astore.setearlier in the same run is readable by a laterstore.get(same row), andstore.set's own output also lands invars. For pure same-run data passing, plainvars(no DB) is still the cheaper path; reach for the store when you want durability. - Later run — a separate execution reads the same row with
store.get/links.lookup(+saveAs) and references it identically. This is the only way to share state across runs, sincevarsdies with the run.
So one mental model: vars = ephemeral fast path; store = durable shared path; both surfaced through the same saveAs → { $expr: "vars.x" } mechanism. The store node is what rehydrates durable state into the current run's vars.
Data model
Two tables, both: tenant uuid NOT NULL (distribution column) → FK to tenants, composite PK including tenant, all unique/index keys lead with tenant (Citus requirement), distributed colocate_with => 'workflow_runs' so they land in the workflow colocation group.
created_by_run_id is a soft reference (plain nullable uuid, no FK). Store/link data deliberately outlives runs — runs are pruned/retained on a different schedule, and we do not want store entries cascade-deleted when a run is cleaned up.
workflow_data_store (KV)
| column | type | notes |
|---|---|---|
tenant |
uuid NOT NULL | FK → tenants.tenant; distribution column |
store_id |
uuid NOT NULL | gen_random_uuid() |
namespace |
text NOT NULL | free-form collection |
key |
text NOT NULL | key within namespace |
value |
jsonb NOT NULL | arbitrary JSON; size-capped |
value_type |
text NOT NULL default 'json' |
designer hint: string / number / boolean / json |
revision |
bigint NOT NULL default 1 | optimistic concurrency (compare-and-set) |
expires_at |
timestamptz NULL | optional TTL |
created_by_run_id |
uuid NULL | soft ref |
created_at / updated_at |
timestamptz | set in model code |
- PK
(tenant, store_id) - UNIQUE
(tenant, namespace, key)— logical identity - INDEX
(tenant, namespace) - Partial INDEX
(tenant, expires_at)WHEREexpires_at IS NOT NULL— janitor sweep
workflow_entity_links (mapping)
| column | type | notes |
|---|---|---|
tenant |
uuid NOT NULL | FK → tenants.tenant; distribution column |
link_id |
uuid NOT NULL | gen_random_uuid() |
namespace |
text NOT NULL | e.g. project-task-mirror |
left_type / left_id |
text NOT NULL | e.g. project_task / <taskA> |
right_type / right_id |
text NOT NULL | e.g. project_task / <taskB> |
relation |
text NOT NULL default 'related' |
semantic label (mirrors, maps_to, …) |
attributes |
jsonb NOT NULL default '{}' |
per-edge metadata (e.g. field map) |
created_by_run_id |
uuid NULL | soft ref |
created_at / updated_at |
timestamptz | set in model code |
- PK
(tenant, link_id) - UNIQUE
(tenant, namespace, left_type, left_id, right_type, right_id, relation)— the (left, right) pair is intentionally NOT unique; uniqueness is on the typed edge (incl.relation). So the same pair can carry multiple connection types at once (e.g. two different workflows establishingmirrorsandblocksbetweentaskAandtaskB), while re-running the same typed edge stays idempotent (cleanupsert). Everything else is N:M. - INDEX
(tenant, namespace, left_type, left_id)— forward lookup - INDEX
(tenant, namespace, right_type, right_id)— reverse lookup
Identifiers are text (not uuid) so the store maps anything: uuids, external string ids, emails, composite keys.
Migration (single CE file, no split)
server/migrations/20260604120000_create_workflow_data_store_tables.cjs, modeled exactly on 20260429133000_create_asset_facts_table.cjs:
exports.config = { transaction: false }(required forcreate_distributed_table).createTablefor both tables withtenant uuidfirst, composite PK/unique/index as above.- Detect Citus via
pg_extension; if enabled and not already distributed,SELECT create_distributed_table('workflow_data_store', 'tenant', colocate_with => 'workflow_runs')and the same forworkflow_entity_links. Elseconsole.warnand skip (plain Postgres for CE/dev). downdrops both tables.
No ee/server/migrations file. No expand/contract dance — the tables are born distributed-ready (tenant uuid from row zero), avoiding the retrofit pain the existing V2 tables went through.
Persistence models
shared/workflow/persistence/workflowDataStoreModel.ts and workflowEntityLinkModel.ts, mirroring the *V2 model style (tenant-scoped, explicit tenant param, Knex):
- KV:
get,set(insert-or-update on(tenant,namespace,key); bumpsrevision; optionalif_revisioncompare-and-set returning a conflict signal),delete,increment(atomic numeric update under the row),list(namespace + optional prefix, cursor pagination),listNamespaces,deleteExpired(janitor). - Links:
upsert(onConflict on the unique edge → updateattributes/relation),lookup(forward/reverse/either; optionalrelation/right_typefilter),delete(by partial criteria; requires at least one of left/right),list,listNamespaces.
Actions (registry)
New module(s) under shared/workflow/runtime/actions/businessOperations/ (dataStore.ts for KV, entityLinks.ts for links), registered from runtime/init.ts. All writes are sideEffectful with idempotency via actionProvidedKey, wrapped in withTenantTransaction, gated by requirePermission, and audited via writeRunAudit. Reads are non-side-effectful but tenant-scoped. Zod in/out schemas with withWorkflowJsonSchemaMetadata for the designer.
KV (palette group store):
store.get→{found, value, value_type, revision, expires_at}store.set{namespace, key, value, value_type?, ttl_seconds?, if_revision?}→{revision, created}(CAS mismatch →ActionError CONFLICT)store.delete{namespace, key}→{deleted}store.increment{namespace, key, by?=1, initial?=0}→{value, revision}store.list{namespace, prefix?, limit?<=200, cursor?}→{items[], next_cursor}store.list_namespaces→{namespaces:[{namespace, key_count}]}
Links (palette group links):
links.upsert{namespace, from:{type,id}, to:{type,id}, relation?, attributes?}→{link_id, created}(author-facing field names arefrom/to; the DB columns remainleft_*/right_*)links.lookup{namespace, from:{type,id}, direction?='forward'|'reverse'|'either', relation?, right_type?, limit?<=200}→{matches:[{link_id, type, id, relation, attributes}]}links.delete{namespace, from?, to?, relation?}→{deleted_count}(≥1 of from/to required)links.list{namespace, left_type?, right_type?, relation?, limit?, cursor?}→{items[], next_cursor}links.list_namespaces→{namespaces:[{namespace, link_count}]}
Designer integration
Add a "Data Store" catalog group in runtime/designer/actionCatalog.ts exposing store.* and links.*. namespace is free-text with autocomplete sourced from *.list_namespaces. left/right ids are populated from upstream action outputs via expressions ({ $expr: "vars.created.task_id" }). Three "label" fields — namespace, entity type, and link relation — share one UX: a soft-enum combobox that suggests (a) a curated constant list and (b) values already used in the chosen namespace (a lightweight SELECT DISTINCT helper, same idea as list_namespaces), while still accepting a custom free-text value. None are enforced enums at the DB/schema level (all stay free-form text). Curated defaults: types = project_task, ticket, contact, client, project, appointment, quote, …; relations = related (default), mirrors, maps_to, blocks, duplicate_of, synced_with.
On links.upsert the author sets relation via that combobox (default related); on links.lookup an optional relation filter uses the same combobox (empty = match any relation). The from/to record ids are expression inputs sourced from upstream outputs ({ $expr: "vars.created.task_id" }). direction (forward/reverse/either) is a small segmented control. Author-facing labels use plain language (Collection, From record, To record, Relationship) with the engineer-y idempotency_key described as advanced/optional.
Components (design-system only; no native HTML controls). All fields use AlgaPSA @alga-psa/ui components — never native <select>/<input>/<textarea>:
- soft-enum comboboxes (namespace, entity
type,relation) reuse the existing creatable-combobox pattern (TagInput/ComboBoxFieldSelectorprecedent): filtered suggestions + "add new" free text; - fixed choices (
direction,value_type) useCustomSelect; - id/value fields reuse the designer's existing expression input (
ExpressionAutocomplete) and@alga-psa/uiInput; - the
value(JSON/payload) editor reuses the designer's existing fixed-value/JSON editor components, not a raw textarea.
TTL / janitor
Lazy expiry on read (store.get treats expires_at < now() as not-found and opportunistically deletes), plus a periodic sweep bolted onto the existing WorkflowRuntimeV2Worker poll loop calling deleteExpired(tenant) in batches.
Limits / abuse guards
valuecapped atWORKFLOW_STORE_MAX_VALUE_BYTES(default 256 KB, matching snapshot caps); oversize →ValidationError.namespace/key/*_idlength caps (e.g. 256 chars).- No per-tenant row quota (out of scope; revisit only if abuse is observed in practice).
Localization (i18n)
All new designer UI must be fully localized — the workflow surface was migrated in the 2026-04-18-msp-i18n-workflows effort and must not regress.
- Strings via
t()under the existingmsp/workflowsnamespace (useTranslation('msp/workflows')from@alga-psa/ui/lib/i18n/client). Add keys for the "Data Store" palette group, node labels/descriptions, every field label/placeholder, empty states, and validation messages. - No hardcoded option arrays — the curated entity-
typeandrelationsuggestion lists follow theenum-labels-pattern.mdoption-hook pattern:VALUES+LABEL_DEFAULTSconstants plususeEntityTypeOptions()/useRelationOptions()hooks (colocated inee/packages/workflows/src/hooks/). The stored value stays the canonical token (project_task,mirrors); only the display label is translated. - Action palette metadata — the
ui.label/ui.descriptionforstore.*/links.*actions must resolve through i18n keys (consistent with how existing action palette entries are localized), not hardcoded English in the action registry. - Locale files — add the new keys to all nine
server/public/locales/*/msp/workflows.jsonplus pseudo-localesxx/yy. - Validation/QA —
node scripts/validate-translations.cjspasses with zero missing/extra keys across 9 locales;xxpseudo-locale QA shows no English bleed-through on the Data Store nodes.
Security / RBAC
Reuse the existing workflow permission resource (actions: read, view, manage, publish, admin) — no new resource. Read actions (store.get, store.list, *.list_namespaces, links.lookup, links.list) require workflow:read; write actions (store.set, store.delete, store.increment, links.upsert, links.delete) require workflow:manage. No seed/permission migration needed since the rows already exist. The permission is checked against the workflow's actor (publisher/creator, resolved by resolveRunActorUserId), consistent with other business-operation actions. Tenant isolation is enforced by the tenant distribution column and set_config('app.current_tenant', …) inside withTenantTransaction.
Observability
- Writes recorded in
audit_logsviawriteRunAudit(operationstore.set/links.upsert/etc., with namespace/key/edge in details). - Action invocations already logged in
workflow_action_invocations(input/output/idempotency), giving per-run traceability for store operations.
Test plan (high level)
- Models: CRUD; revision bump;
if_revisioncompare-and-set (success + conflict);incrementatomicity under concurrency; TTL lazy expiry +deleteExpired; linkupsertidempotency; N:Mlookupforward/reverse/either; duplicate-edge unique conflict;deleteby partial criteria; tenant isolation (no cross-tenant reads). - Actions: Zod schema validation; RBAC denial without permission; idempotent replay (same idempotency key → same result, no double write); value-size cap rejection.
- Citus: both tables distributed and colocated with
workflow_runs(same colocation group); inserts route correctly; CE/dev (no Citus) path works. - Integration: the two reference workflows (link-setup + mirror) run end-to-end against a seeded tenant, including
forEachover multiple matches for a 1:N mirror. - Designer / i18n: Data Store nodes render with
@alga-psa/uicomponents only (no native<select>/<input>); soft-enum comboboxes show suggestions + accept custom values;validate-translations.cjspasses across 9 locales with the new keys;xxpseudo-locale shows no English bleed-through on the Data Store nodes.
Rollout
Purely additive — new tables, models, actions, and a designer group. No data backfill, no changes to existing workflows. The designer "Data Store" group may sit behind a feature flag during rollout if desired. Backward compatible.
Open questions
All initial open questions are resolved and reflected in the design above (both primitives · free-form namespaces · N:M with relation in the typed-edge unique key · single CE migration colocated with workflow_runs · explicit-node writes · any string/JSON/payload values · workflow:read/workflow:manage RBAC · no per-tenant quota · soft-enum comboboxes for namespace/type/relation).
Appendix — reference workflow sketches
Mirror workflow (PROJECT_TASK_UPDATED → update linked tasks):
trigger: { type: event, eventName: PROJECT_TASK_UPDATED }
steps:
- id: lookup
type: action.call
config: { actionId: links.lookup }
input: { namespace: "project-task-mirror",
from: { type: "project_task", id: { $expr: "payload.task_id" } },
direction: "forward" }
- id: fan-out
type: control.forEach
items: { $expr: "vars.lookup.matches" }
itemVar: m
body:
- id: update
type: action.call
config: { actionId: projects.update_task }
input: { task_id: { $expr: "m.id" },
name: { $expr: "payload.changes.name" },
description: { $expr: "payload.changes.description" } }