Specfork

@specfork/crm

Customizable CRM with companies, people, deals, custom objects, views, notes, tasks, import, workflows and mail sync.

saassales Version 0.2.1 117 requirements Licence CC-BY-4.0 Specfork/crm

CRM

A team keeps its customer data in objects: companies, people and opportunities out of the box, plus objects and fields it defines itself. Every object gets the same record operations, API, views, notes, tasks, timeline, import/export and automation. Behaviour derived from Twenty's open-source code (AGPL-3.0), written fresh and stack-free. Files under Twenty's commercial licence (record sharing, row-level permissions, validation rules, SSO, audit logs, billing) were not used. Not affiliated with or endorsed by Twenty.com, PBC. The name is used only to credit the source.

Area Prefix Covers
objects.md OBJ Workspace, system fields, companies, people, opportunities, members, duplicate rules
fields.md FLD Field types, composite values, normalisation, files, actors
data-model.md MOD Custom objects and fields, options, relations, unique fields, deactivation
records.md REC Record operations, batches, trash, position, upsert, duplicates, merge
query.md QRY Filters, sorting, pagination, aggregates, global search
views.md VIEW Table, kanban and calendar views, visibility, favorites
activities.md ACT Notes, tasks, attachments, timeline
import-export.md IO Spreadsheet import, CSV export
permissions.md PERM Team roles applied to records, data model, views and settings
events.md EVN Webhook events, payloads, live updates
workflows.md WF Triggers, steps, versions, runs, limits
mail.md MAIL Email and calendar sync, contact creation, blocklist, visibility

A workspace is a team (@specfork/teams); all data belongs to exactly one workspace. All times are stored in UTC; API names are snake_case.

Activities

Status: Draft · Prefix: ACT

Purpose

Notes, tasks and files attached to records, and each record's timeline of what happened to it.

Requirements

ID Requirement Verified by
ACT-1 Note: {title, body (rich text), position}. Task: {title, body, due_at?, status: todo|in_progress|done (optional, default todo), assignee? → member}. Both are objects (REC) and support custom fields. create task → status todo
ACT-2 A note or task links to any number of records of any object, standard or custom; a (note|task, record) pair is unique. Each linked record lists it. note linked to Acme and Ann → shown on both
ACT-3 Deleting or restoring a note or task does the same to its links; destroying a linked record removes only the link. destroy Acme → note stays, linked to Ann
ACT-4 Tasks: completing sets done, reopening sets todo; a task is overdue when due_at < now and status ≠ done. "My tasks" = tasks assigned to me, not done, by due_at (nulls last), grouped by status. due yesterday, todo → overdue
ACT-5 Attachment: {name, file (files, max 1, [FLD-10](#FLD-10)), parent}; parent is one record of any object, including notes and tasks. Destroying the parent destroys its attachments. destroy person → their files gone
ACT-6 Timeline entry: {record, happens_at, action: created|updated|deleted|restored|linked|unlinked, actor ([FLD-11](#FLD-11)), diff?, linked {object, id, cached_name}?}, newest first, grouped by month. create company → one created entry
ACT-7 Entries are written for record create, update, delete, restore; note, task, attachment link and unlink on each linked record; a change to a linked note's or task's title; synced emails and calendar events linked to the record (MAIL-8), placed at their own time. link note → linked entry on company
ACT-8 Update diff {field: {before, after}} covers only audited fields (MOD-3); it never includes updated_at, position, search data or relation fields; an empty diff writes no entry. change position only → no entry
ACT-9 An update by the same actor on the same record within 10 minutes of that actor's last update entry merges into it: earliest before, latest after per field. edit name twice in 2 min → one entry
ACT-10 A reader sees diff fields only if they may read the field; an entry left empty is hidden. Linked entries keep cached_name after the linked record is destroyed. Destroying a record destroys its own entries. destroyed note → company entry still names it

Out of scope

  • Comments and mentions on records, reminders and notification inbox, collaborative real-time editing of note bodies.

Decisions

  • Attachments are destroyed with any parent; the source kept them (unlinked) for note and task parents only.
  • Timeline entries of any destroyed record are destroyed; the source did so for people and companies only.

Data model

Status: Draft · Prefix: MOD

Purpose

Admins add objects and fields at run time; the API, views, search, import and automation pick them up without a deploy.

Requirements

ID Requirement Verified by
MOD-1 Custom object: {name_singular, name_plural, label_singular, label_plural, description?, icon?, active (default true), searchable (default true)}. Names match ^[a-z][a-z0-9_]{0,62}$, singular ≠ plural, and are unique across every object's singular and plural names; names of system objects and API words (e.g. object, field, view, search, and, or, not) are reserved → reserved_name. Labels 1–63 chars, trimmed. Names default to snake_case of the labels (digit-first → n prefix; reserved → _custom suffix). object named views → reserved_name
MOD-2 Creating an object adds the system fields (OBJ-2), a name text field as label field, search on it (QRY-9), a default view (VIEW-2), and makes it linkable from notes, tasks, attachments and timeline (ACT). new object project → note can link a project
MOD-3 Custom field: {name, label (1–63), type (FLD-1), description?, icon?, required (default false), unique (default false), default?, options?, settings?, active (default true), searchable (default false), audited (default true)}. Name rules as MOD-1, unique within the object including relation reference columns and composite parts. Type can't change after creation. change field type → refused
MOD-4 A required field needs a non-null default; turning required on fills existing nulls with it. Relation, files and actor fields take no default. make industry required with default other → empty records get other
MOD-5 Select/multi-select options: non-empty list of {id, value, label, color, position}; value matches ^[a-z][a-z0-9_]{0,62}$; label 1–63 chars, no comma; ids, values and positions unique; color from a fixed palette of 25 named colours (default gray). A select default must be one option; a multi-select default a non-empty set of options. two options won → refused
MOD-6 Editing options matches them by id: a changed value is rewritten in every record, view filter and kanban column; a removed option is cleared from records (select → null, multi-select → dropped from the set) and its kanban column removed. remove meeting stage → those deals' stage null
MOD-7 Relation field: {kind: many_to_one|one_to_many, target_object, inverse_label} creates the inverse field on the target. The many-to-one side holds the reference and on_destroy: set_null (default) | cascade | restrict; it applies on destroy (REC-3), never on delete. Deleting either side deletes both. restrict + destroy target → refused
MOD-8 Many-to-many uses a junction object with two many-to-one fields; a one-to-many field may name the junction's other field to read the far records directly. project ↔ person via assignment → project lists people
MOD-9 unique is allowed on text, number, UUID, emails (primary), links (primary URL), phones (primary) and many-to-one relations, not with a non-empty default. Turning it on while duplicates exist → duplicates_exist with up to 10 offending values. Deleted (trashed) records count (REC-8). enable unique with two equal values → duplicates_exist
MOD-10 Editable later: labels, names (custom only), description, icon, active, searchable, unique, required, default, options, settings. Standard objects/fields: labels, description, icon, active, searchable only. System fields: nothing. Renames keep data, views and filters. rename custom field → views still show it
MOD-11 Deactivating a field or object hides it from views, forms, filters, search, import, export, workflows and the API (reads return no value; writes → unknown_field); data is kept and comes back on reactivation. Views grouped by the field, and its view columns, filters and sorts, are removed. The label field can't be deactivated or made unsearchable. deactivate stage → kanban view on stage removed
MOD-12 Only custom objects and fields can be deleted, and only while inactive. Deleting removes their data permanently, the inverse relation field, indexes and views. delete active custom field → deactivate_first
MOD-13 Every data-model change takes effect for all members and API clients without a restart; the API schema lists active objects and fields. add field → next API read includes it

Out of scope

  • Remote (external database) objects, formula fields, per-object validation rules, data-model versioning as code.

Decisions

  • snake_case names (the source used camelCase) to match other Specfork specs.
  • Delete only after deactivation, a safety step the source didn't have.
  • Enabling unique reports the duplicates instead of failing the migration.
  • No limit on object or field count: none was found in the source.

Events

Status: Draft · Prefix: EVN

Purpose

What the CRM tells the outside world (webhooks) and open clients (live updates) when records change.

Requirements

ID Requirement Verified by
EVN-1 Every committed record change emits one event per record: <object>.created, .updated, .deleted, .restored, .destroyed, with <object> the object's singular name (custom objects included). Upsert emits created or updated; merge emits updated for the primary and deleted for the others. A failed or rolled-back write emits nothing. create-many 3 people → 3 person.created
EVN-2 Webhook subscriptions (@specfork/webhooks:HOOK-1) have scope = team and list event names from EVN-1; deliveries follow the webhooks base. subscribe company.updated → only company updates delivered
EVN-3 Webhook data: {object, record (state after; for deleted and destroyed, the last state), before? (updated only), updated_fields? (updated only), actor ([FLD-11](#FLD-11))}; files appear as {file_id, name}, never contents. update phone → updated_fields = ["phones"]
EVN-4 Data-model changes emit metadata.object.created|updated|deleted and metadata.field.created|updated|deleted with the new definition. add field → metadata.field.created
EVN-5 Live updates: a member viewing a list, board or record sees other members' changes within 5 s without reloading, limited to records and fields they may read. A edits deal, B's board moves card
EVN-6 Workflow triggers (WF-3) receive the same events as webhooks. opportunity.updated → workflow on stage change runs

Out of scope

  • Outbound email notifications and an in-app notification inbox.

Decisions

  • Event names use dots and the object's singular name, like the source; wildcard subscriptions are proposed for the webhooks base rather than defined here.

Fields

Status: Draft · Prefix: FLD

Purpose

Field types, the shape of their values, and how values are checked and normalised on every write.

Requirements

ID Requirement Verified by
FLD-1 Types: text, number (settings kind: int|float, decimals 0–8, display: number|percent), boolean, date, date_time, select, multi_select, rating (1–5), currency, full_name, emails, phones, links (setting form: url|domain), address, rich_text, files, array (strings), json, uuid, relation, actor. Display settings never change stored values. percent field 0.25 → stored 0.25
FLD-2 Composite values: currency {amount_micros (integer, 10⁻⁶ units), currency_code (ISO 4217)}; full name {first, last}; emails {primary, additional []}; phones {primary {number, country (ISO 3166-1 alpha-2), calling_code}, additional [same]}; links {primary {url, label}, additional [same]}; address {street1, street2, city, postcode, state, country, lat, lng}; rich text {markdown, blocks}; actor {source, member_id?, name, context?}. Unknown sub-fields → unknown_field. currency {amount: 5} → unknown_field
FLD-3 Empty means null: "" in text and [] in lists are stored as null; a single string sent to an array is wrapped. Unknown or inactive field → unknown_field; null for a required field with no default → required. create opportunity with stage: null → required
FLD-4 Emails: every address is an addr-spec with a dotted domain (else invalid_email), lower-cased, trimmed; additional loses duplicates and the primary. Ann@X.com → ann@x.com
FLD-5 Links: blank entries dropped; an empty primary takes the first additional; invalid URL → invalid_url. url form: scheme and host lower-cased, trailing / dropped when there is no query or fragment. domain form: host only (no scheme, path, user, port, leading www., trailing dot), lower-cased, IDN to punycode. https://WWW.Acme.com/about in domain form → acme.com
FLD-6 Phones: parsed per E.164; country and calling_code filled from the number when missing; a number, country or calling code that is invalid or contradicts the others → invalid_phone. +33 6 12 34 56 78 → country FR, calling code +33
FLD-7 Rich text: a write may give markdown or blocks; the other is derived and both stored. write markdown only → blocks returned
FLD-8 Select and rating values must be an option value; multi-select values a set of option values (duplicates removed). Else invalid_option. select maybe with no such option → invalid_option
FLD-9 Currency: amounts are integer micros; a non-null amount needs a currency_code (default: field default, else workspace default_currency). amount 1.5 → amount_micros 1500000
FLD-10 Files: value [{file_id, name}], at most the field's max_files (1–60). Uploads: > 0 bytes and ≤ 1 GB (instance setting), images for avatars ≤ 10 MB; file names non-empty without / or \; SVGs sanitised and ≤ 3 MB; only image, PDF, plain-text, audio and video types are served inline, the rest as downloads. Stored files are removed only when their record is destroyed. upload a/b.pdf → refused
FLD-11 Actor sources: manual, api, import, workflow, webhook, email, calendar, system. The server sets updated_by on every write and created_by on create from the caller: member → manual + member name and id; API key → api + key note; workflow → workflow + workflow name (WF-12); mail sync → email|calendar + account owner. A client may only mark its own creates as import. API key creates person → created_by.source = api

Out of scope

  • Formula and computed fields, field-level validation rules, encrypted fields.

Decisions

  • Raw numeric (arbitrary precision) dropped; number with kind covers it.
  • Clients can't forge actor sources other than import; the source kept any client-sent source.

Import and export

Status: Draft · Prefix: IO

Purpose

Bring records in from spreadsheets and take them out as CSV.

Requirements

ID Requirement Verified by
IO-1 Import one file into one object: CSV (comma or semicolon, UTF-8, header row) or XLSX (first sheet); ≤ 10,000 data rows, else refused before mapping. 10,001 rows → refused
IO-2 Mapping: each column maps to one field or sub-field, or is skipped; suggestions come from fuzzy match on field name and label. Importable: active, writable, non-system fields plus id; many-to-one relations by a unique field of the target (e.g. company by domain); actor fields not importable. column "Company domain" → suggested company by domain
IO-3 Per-cell checks before import: number, UUID, email, URL/domain, date YYYY-MM-DD, date-time ISO 8601, phone, country, calling code, select by value or label; booleans true|false|yes|no|1|0 (any case), anything else invalid. Invalid cells are shown for fixing or the row skipped. maybe in boolean column → flagged
IO-4 Rows repeating a unique value inside the file are flagged before import (links compared after FLD-5 normalisation). two rows acme.com → flagged
IO-5 Import runs as upsert (REC-7) in batches of 200, created_by.source = import; it can be cancelled between batches; the result gives created, updated and failed counts with row errors. Workflows and webhooks fire per record (EVN-1). re-import same file → 0 created, all updated
IO-6 Export: CSV of a view (its filter, sort and visible columns) or of selected records, as a background job; one running export per workspace (else conflict); stops at 1 GiB or 1 h; download via a single-use link valid 60 s after it's issued. Only fields the requester can read are included. second export while one runs → conflict
IO-7 Export format: UTF-8 with BOM, header row of labels, first column id; composite fields as one column per sub-field ("Label / Sub"); relations as "Label Id"; currency as decimal amount; dates YYYY-MM-DD; date-times ISO 8601 UTC; cells starting with =, +, -, @, tab or CR are prefixed so spreadsheets don't run them. name =1+1 → cell not evaluated

Out of scope

  • Migration connectors from other CRMs, scheduled exports, XLSX export.

Decisions

  • Invalid booleans are errors; the source silently imported them as false.

Mail and calendar sync

Status: Draft · Prefix: MAIL

Purpose

Members connect mailboxes and calendars; messages and events attach to the people and companies involved.

Requirements

ID Requirement Verified by
MAIL-1 Connected account {owner (member), provider: google|microsoft|imap_smtp_caldav, handle (email), aliases [], scopes, status}; credentials encrypted at rest; one message channel and one calendar channel each. Only the owner sees or manages it (PERM-4); the owner leaving the team disconnects it (MAIL-12). other member lists accounts → doesn't see it
MAIL-2 Message channel: visibility: metadata|subject|everything (default metadata), create_contacts: none|sent|sent_and_received (default sent), folders: all|selected (default all), skip_personal_domains (default true), skip_group_addresses (default true), sync_enabled (default true). Calendar channel: visibility: metadata|everything (default metadata), create_contacts (bool, default true), skip_private_events (default false), sync_enabled. new channel → metadata visibility
MAIL-3 Sync: an initial import (mail from the last 365 days; calendar from 365 days back to 365 days ahead; instance settings), then incremental changes at least every 5 min, or by provider push with a poll when no sync happened for 1 h. Trash, spam and junk are never imported; provider bulk categories (promotions, social, updates, forums) are skipped under all. Status: not_synced|ongoing|active|failed_permissions|failed. message in spam → not imported
MAIL-4 Failures: temporary errors back off 1, 2, 4, 8, 16 min, then failed; failed channels are retried every 30 min; revoked or insufficient credentials → failed_permissions, owner asked to reconnect, no retry; an invalid sync cursor → full re-list. A stage stuck > 30 min is restarted. 5 timeouts → failed, retried 30 min later
MAIL-5 Import filters, in order: drop if any participant is blocklisted (MAIL-9); drop if every participant is on the owner's own work domain (workspace setting can allow internal mail); with skip_group_addresses, drop mail not sent by the owner that has bulk-mail headers or comes from role addresses (noreply, info, contact, hello, support, team, notifications, news, …). Calendar: cancelled events removed; private events skipped if set; events with a blocklisted attendee skipped. newsletter with List-Unsubscribe → not imported
MAIL-6 A message is stored once per workspace by Message-ID and linked to every channel holding it. Thread: the provider's thread id, else first References id, else In-Reply-To, else its own Message-ID; a thread's subject is its latest message's. same email in 2 members' boxes → one message, 2 channels
MAIL-7 Participants match people by any of their emails (case-insensitive; oldest live person wins) and members by email; matches are recomputed when a person is created, destroyed or their emails change. add email to Ann → past threads link to Ann
MAIL-8 Each message thread and calendar event is linked to its matched people and their companies, listed on those records and on their timeline (ACT-7) at the message or event time. email with Ann → on Ann and Acme timelines
MAIL-9 Blocklist entries: an email or @domain (covers subdomains), case-insensitive, scope member (default) or workspace (PERM-1); own handles can't be blocked. Adding one deletes matching imported messages in its scope; removing one re-syncs the affected channels. block @spam.io → existing messages from a.spam.io removed
MAIL-10 Contact creation (per create_contacts; sent = only mail the owner sent): for participants that aren't the owner, aliases, members, the owner's work domain, personal-provider domains (if skipped) or group addresses (if skipped): reuse a person with that email (restoring a trashed one), else create one named from the display name; for work domains reuse or create a company with that domain, named from it. Actor email or calendar (FLD-11). owner mails bob@globex.com → person Bob + company Globex
MAIL-11 Visibility: anyone sees everything if any linked channel shares everything, and the owner of a linked channel always does; subject hides the body; metadata hides subject and body (calendar: title and description), keeping participants and times. Applies to UI, API, search, export and timeline. metadata channel → other member sees sender, not subject
MAIL-12 Disconnecting stops sync and push, revokes the provider grant where possible, deletes the channels and every message, thread and event no longer linked to a channel. People and companies created stay. disconnect → thread gone; person Bob remains

Out of scope

  • Composing and replying from the CRM (except workflow send-email), SMS, email campaigns and tracking, shared team inboxes.

Decisions

  • Initial import limited to 365 days (configurable); the source imported whole mailboxes.
  • Calendar contact creation is a boolean; the source's organizer/participant policy values were unused.

Objects

Status: Draft · Prefix: OBJ

Purpose

What every record has, and the standard objects a new workspace starts with.

Requirements

ID Requirement Verified by
OBJ-1 A workspace is a team (@specfork/teams:TEAM-1). Workspace settings: trash_retention_days (integer ≥ 0, default 14, REC-5), default_currency (ISO 4217, default USD). A new workspace gets the standard objects OBJ-4..7, notes and tasks (ACT), each with its default view (VIEW-2). create team → companies, people, opportunities exist, empty
OBJ-2 Every record of every object has system fields: id (UUID v4; a client may supply one on create), created_at, updated_at, deleted_at? (REC-3), created_by, updated_by (actor, FLD-11), position (finite number, REC-6). Clients can't write them except id on create, position, and per FLD-11. update created_at → read_only
OBJ-3 Each object names one label_field (text, full name or UUID; default its name field) used as the record's title in lists, links, search and timeline, and an optional image_field (files or links, same object, active). person titled by full name
OBJ-4 Company: name (text), domain (links, domain form, max 1 value, unique, FLD-5), address, linkedin (links), annual_revenue (currency), account_owner → member (many-to-one, set null). Label name; image domain (logo looked up from the domain). two companies acme.com → second conflict
OBJ-5 Person: name (full name), emails (unique on primary), phones, linkedin (links), job_title (text), avatar (files, max 1), company → company (many-to-one, set null). Label name; image avatar. destroy company → its people keep existing, company empty
OBJ-6 Opportunity: name (text), amount (currency), close_date (date), stage (select, required, default new; options new, qualified, meeting, proposal, won, lost, editable like any select MOD-5), company → company, point_of_contact → person, owner → member (all many-to-one, set null). create without stage → new
OBJ-7 Member: one read-only record per accepted membership (@specfork/teams:TEAM-2) with name, email, avatar, and own-editable preferences time_zone (IANA or system), date_format (system|month_first|day_first|year_first), time_format (system|24h|12h), number_format (system|1,234.5|1 234,5|1.234,5|1'234.5). Leaving or removal (@specfork/teams:TEAM-8) empties every relation pointing at the member. removed member's companies → account_owner empty
OBJ-8 Duplicate rules (REC-9) per object, as a list of field groups: company: name, or domain; person: name (first + last), or primary email, or primary linkedin. Custom objects start with none; admins can set groups of up to 3 fields. person "Ann Lee" twice → flagged
OBJ-9 Standard objects and fields can be renamed in labels, hidden (deactivated, MOD-11) and extended with custom fields, never deleted or retyped. delete person.emails → refused

Out of scope

  • Dashboards and charts, AI agents and chat, apps/extensions platform, email campaigns, call recordings.
  • Company enrichment from external data providers.

Decisions

  • Opportunity stages add won and lost; the source's default pipeline had no closing stages.
  • Member is a projection of team membership, so roles and invites stay in @specfork/teams.

Permissions

Status: Draft · Prefix: PERM

Purpose

Team roles (@specfork/teams:TEAM-2) applied to CRM resources.

Requirements

ID Requirement Verified by
PERM-1 Resource rows (@specfork/teams:TEAM-10): records of any object: read, create, update, delete, restore, merge: member+; destroy: admin+. Workspace views: create, update, delete: member+. Shared favorites: admin+. Import, export: member+. Data model (MOD), duplicate rules, workspace settings (OBJ-1): admin+. Workflows: read, run manual: member+; create, edit, activate, deactivate: admin+. Webhook subscriptions (scope = team): admin+. Workspace blocklist (MAIL-9): admin+. member destroys record → 403
PERM-2 The same check applies to every path that reads or writes records: UI, API, search, aggregates, export, timeline, workflow steps and merge. member export of companies → allowed; then destroy via API → 403
PERM-3 A user-scoped API key acts as its user. A team-scoped API key (@specfork/auth:AUTH-14) acts with the current role of the member who created it, and stops working when that member leaves the team. creator demoted to member → key can't destroy
PERM-4 Personal data stays personal: personal views and favorites (VIEW-3), connected accounts and personal blocklist entries (MAIL) are visible and editable only by their owner, whatever their role. admin reads another member's personal view → 404

Out of scope

  • Custom roles, per-object and per-field permissions, row-level rules and record sharing (proposed for the teams base; row-level and sharing are commercial in the source).

Decisions

  • Fixed member/admin/owner roles from @specfork/teams instead of the source's custom roles, until the base has them.
  • Team API keys borrow their creator's role, since the auth base keys carry no role.

Query

Status: Draft · Prefix: QRY

Purpose

Filter, sort, page and aggregate any object's records, and search across objects.

Requirements

ID Requirement Verified by
QRY-1 A filter is a tree of and, or, not nodes and conditions {field[.sub_field], op, value}. Relations: filter many-to-one by id or by the target's fields (one hop); filtering or sorting through one-to-many → unsupported. filter people by company.name → works
QRY-2 Operators: text, emails, links, phones (sub-fields): eq, neq, in, contains, starts_with, ends_with (case- and accent-insensitive); number, date, date-time, UUID: eq, neq, gt, gte, lt, lte, in; boolean: eq; select, rating: in, not_in; multi-select, array: contains_any, is_empty; all: is_null, is_not_null. Wrong operator for the type → bad_filter. amount_micros gt 1000000000 → deals over 1,000
QRY-3 Empty equals null: is_null also matches "" and []; neq and not_in never match nulls. job_title neq "CEO" → excludes people without title
QRY-4 Date filters accept relative values: today, past/next N days|weeks|months|years, in the past, in the future, evaluated in the caller's time zone (OBJ-7). "next 7 days" on Monday → through next Monday
QRY-5 Sort: ordered list of {field[.sub_field], dir: asc|desc, nulls: first|last (default last)}; id is always the final tie-breaker; text compares case-insensitively; select sorts by option position, not value. sort by stage → new before won
QRY-6 Pages: opaque cursors over the sort keys + id; first + after or last + before (mixing → bad_request); default 60, max 200 (larger → 200); response has has_next, has_previous, start and end cursors. total is returned on request and ignores the cursor. 130 records, page 60 → 3 pages, total 130 each
QRY-7 Reads may expand relations: to-one fully, to-many up to 60 per parent; a to-many inside a to-many → too_complex. person with company → company fields inline
QRY-8 Aggregates over a filter, optionally grouped by a select or many-to-one field: count, count_empty, count_not_empty, count_unique, count_true, count_false, percent_empty, percent_not_empty, min, max, avg, sum. sum of amount by stage → one total per stage
QRY-9 Global search spans searchable objects and their searchable fields (label field always; person: name, emails, phones, job title; company: name, domain; opportunity: name; note, task: title, body). Each term is a prefix match, case- and accent-insensitive; records matching all terms rank above those matching some; ties by object: person, company, opportunity, note, task, custom. ≤ 100 per page, cursor-paged; deleted and unreadable records excluded. "ann ac" → Ann at Acme first
QRY-10 Searchable field types: text, full name, emails, phones, links, address, rich text, UUID. Changes to a record are searchable within 5 s. rename company → found by new name

Out of scope

  • Regular-expression filters, vector/semantic search, saved queries outside views.

Decisions

  • Select sorts by option order so pipelines sort by stage order; the source sorted by value text.
  • Default nulls last; the source defaulted to nulls first.

Records

Status: Draft · Prefix: REC

Purpose

The operations every object gets, the same in UI and API.

Requirements

ID Requirement Verified by
REC-1 Every active object, standard or custom, has: get, list (QRY), create (one or many), update (one, or many by filter), delete (to trash), restore, destroy (permanent), upsert, find duplicates, merge. They are served by a JSON HTTP API authenticated by session (@specfork/auth:AUTH-13) or API key (@specfork/auth:AUTH-14), checked by PERM, and the UI uses the same operations. custom object → all operations work via API key
REC-2 Batches: create-many ≤ 200 records (else too_many); an update-many touching > 200 records → too_many; update/delete/restore/destroy-many need a non-empty filter (else filter_required). A batch is atomic. create 201 → too_many, none created
REC-3 Delete sets deleted_at; deleted records are hidden from get, list, search, views and relations unless the query filters on deleted_at. Restore clears it. Destroy removes the record (deleted or not), applies each relation's on_destroy (MOD-7) and removes its files. Missing id → not_found. delete then list → absent; filter deleted → present
REC-4 Deleting a note or task deletes its links; restoring restores them (ACT-3). Other relations are untouched by delete. delete note → hidden on linked company; restore → back
REC-5 A daily job destroys records whose deleted_at is more than trash_retention_days (OBJ-1) days old; 0 → destroyed on the next run. retention 14, deleted 15 days ago → gone
REC-6 position on create or update: a number, first or last; missing → first. first = current minimum − 1, last = maximum + 1 (1 when empty), continuing −1/+1 within a batch. NaN or infinite → invalid_position. create 2 with last on max 5 → 6, 7
REC-7 Upsert matches by id, then by each unique field whose value is given; matching two different records → ambiguous_match; a matched deleted record is restored and updated; no match → created. upsert email of trashed person → restored, updated
REC-8 Unique values include deleted records: a create clashing with a live record → conflict, with a deleted one → conflict_in_trash carrying its id so the client can offer restore. create company with trashed domain → conflict_in_trash
REC-9 Find duplicates (objects with duplicate rules, OBJ-8): input is record ids or field values, not both; a rule group counts only if each of its values has ≥ 3 chars; values compared trimmed and case-insensitively; the record itself excluded; ≤ 200 results per input. "ann lee" vs "Ann Lee" → duplicate
REC-10 Merge 2–9 records of one object into a chosen primary, in one transaction: each field takes the primary's value, else the first non-empty in the given order; emails, phones, links keep the primary's primary value and combine all others into additional without duplicates; arrays and multi-selects are unioned. Records pointing at the merged ones (relations, notes, tasks, attachments, timeline) are re-pointed to the primary. The others go to the trash. dry_run returns the result without saving. merge 2 people with 3 emails → 1 primary + 2 additional
REC-11 A re-point during merge that would break a unique field moves that related record to the trash instead, and the merge result lists it. merge where both have junction row to same project → one row trashed, listed
REC-12 Concurrent updates to different fields of one record both persist; the same field: last write wins. Every write sets updated_at. two members edit name and phone → both kept

Out of scope

  • Per-record sharing and row-level access (commercial in the source), record versioning/undo beyond the trash.

Decisions

  • Merged-away records go to the trash; the source destroyed them.
  • Duplicate matching is case-insensitive; the source compared exactly.
  • REST/GraphQL specifics are not specified; any JSON API exposing these operations conforms.

Views

Status: Draft · Prefix: VIEW

Purpose

Saved ways to look at an object's records: tables, boards and calendars, shared or personal; plus favorites.

Requirements

ID Requirement Verified by
VIEW-1 View: {object, name, icon?, type: table|kanban|calendar, visibility: workspace|personal, position, compact (default false), filter ([QRY-1](#QRY-1)), sorts ([QRY-5](#QRY-5)), search_text?}; search_text matches any searchable field. Views are ordered by position in the object's view list. two views reordered → order kept for all
VIEW-2 Each object has exactly one default view: table "All ", visibility workspace, all active fields as columns. It can't be deleted or made personal; an object's last view can't be deleted. delete default view → refused
VIEW-3 Personal views are visible and editable only by their creator; API keys can't create them. Workspace views are created and changed per PERM-1. A member without that right may change a workspace view on screen; the change isn't saved, and they can save it as a new personal view. member edits shared view filter → reload shows original
VIEW-4 Columns: per field {visible (default true), width (px, default 180), position, aggregate? ([QRY-8](#QRY-8))}; a field at most once; the label field is always first and visible. move column before name → refused
VIEW-5 Filter groups nest at most 2 levels; select filter values must be existing options (MOD-6 keeps them in step). 3-level filter → refused
VIEW-6 Kanban: group_by is a select or many-to-one field. Columns {value, visible, position}, one per option in option order, plus a "no value" column while the field is optional. New option → column appended (visible while fewer than 40 are visible); removed option → column removed. Settings: hide_empty_columns (default false), column width 150–400 px, one aggregate per column, cards per column page 8|25|50|100 (default 8). add stage option → new column at end
VIEW-7 Dragging a card sets the group field to the column's value and position to the midpoint of its neighbours (top: first − 1; bottom: last + 1; empty column: 1) in one update. Within a column, cards are ordered by position unless the view has sorts. drag deal to won → stage won, between neighbours
VIEW-8 Calendar: start_field (date or date-time) required; optional end_field of the same type, not the start field; layout day|week|month. end field = start field → refused
VIEW-9 Each member's last-used view per object reopens on return. open view B, leave, return → B
VIEW-10 Favorites: items {kind: object|view|record|folder|link, target, position, folder?, owner?}; with owner personal, without it shared with the workspace (PERM-1). Folders nest at most 2 levels; deleting a folder deletes its items; links are https URLs. Items pointing at a deleted record or view are removed. delete favorited company → favorite gone

Out of scope

  • Record page layout editing, dashboards and widgets, list (mobile) views.

Decisions

  • Default view of opportunities is a table like any object; the source special-cased it.

Workflows

Status: Draft · Prefix: WF

Purpose

No-code automations: a trigger starts a run that executes steps on records, email and HTTP.

Requirements

ID Requirement Verified by
WF-1 Workflow {name, versions, last_published}; version {trigger, steps, status: draft|active|deactivated|archived}, new ones draft. At most one active version per workflow; at most one draft. Editing an active workflow edits its draft; the active version keeps running. Any version can be copied into the draft. edit active → draft created, runs use active
WF-2 Activate needs a trigger with valid settings and ≥ 1 valid step; allowed from the draft or the last published deactivated version. The previous active version becomes archived, atomically; a version changed since the editor loaded it → conflict. Deactivate only from active. activate draft → old active archived
WF-3 Triggers: record event (object; created|updated|created_or_updated|deleted; for updates an optional field list, firing only if one of them changed; optional record filter, QRY-1); manual (global, or for an object single = one run per selected record, bulk = one run with all selected); schedule (every N minutes, hours or days at a time, or a cron expression, in UTC); webhook (GET or POST to the workflow's URL, POST body schema). update stage with field list [stage] → runs; update name → doesn't
WF-4 A webhook trigger URL contains ≥ 128 random bits, accepts calls only while a version with that trigger is active (else 404), and answers {run_id}. call after deactivate → 404
WF-5 Record steps: create, update (chosen fields), delete (to trash), upsert, find (filter, sort, limit 1–200). Each writes through REC and emits EVN-1 events. find with limit 500 → 200
WF-6 Other steps: send email (from a connected account the workflow's creator owns, MAIL-1; to, cc, subject, body, files); HTTP request (GET|POST|PUT|PATCH|DELETE, headers, body; URL rules of @specfork/webhooks:HOOK-2; 10 s timeout); form (pauses until a member submits text, number, date or record fields); filter (stops the branch if no match); if/else (ordered branches with filters, else branch); iterator (over a list, ≤ 10,000 items, optional continue on item failure); delay (until a date not in the past, or a duration ≤ 365 days). HTTP step to http://10.0.0.1 → step fails
WF-7 Variables {{<step>.<path>}}, trigger for the trigger's output, usable in any setting. A setting that is only one reference keeps the value's type; references inside text render as text, objects as JSON. Unknown reference → empty, logged. {{trigger.record.amount}} alone → number
WF-8 Run {status: not_started|enqueued|running|waiting|completed|failed|stopping|stopped, version snapshot, trigger payload, steps [{status: not_started|running|waiting|success|failed|failed_safely|skipped, input, output, error}], started_at, ended_at, created_by}. A run uses the version snapshot it started with. Members can stop a running or waiting run. activate v2 mid-run → run finishes on v1
WF-9 Step failure: retried retries (0–3) times after 1 s, 5 s, 15 s; then continue_on_failure → failed_safely and the run goes on, else the run fails. Step logs ≤ 256 KB each; HTTP response bodies kept ≤ 32 KB. 500 then 200 with retries 1 → success
WF-10 Throttle per workspace: more than 100 run starts per minute stay not_started and start later, oldest first; more than 5,000 per hour → run created failed with throttled. 150 starts in a minute → 50 start next minute
WF-11 Loop guard: a run triggered by a write from another run carries depth + 1; depth > 5 → the run isn't started and the attempt is logged on the workflow. workflow updating its own trigger object → stops at depth 5
WF-12 Steps write as actor workflow + workflow name, with full record access; a manual run started by a member acts as that member (their role, actor manual). Run created_by: member (manual), workflow (record event, schedule), webhook. member's manual run tries destroy → step fails 403
WF-13 Housekeeping: enqueued > 1 h → re-queued; stopping > 1 h → stopped; running > 24 h with no step progress → failed. Finished runs older than 14 days are deleted, keeping at most 1,000 per workflow. run 15 days old → gone

Out of scope

  • Code steps, AI steps, logic functions, calendar-event creation, record picking strategies, multiple triggers per version.

Decisions

  • Loop guard at depth 5 and a 24 h running timeout; the source had neither.
  • Webhook trigger URLs are unguessable; the source's URL used workspace and workflow ids and its auth setting wasn't enforced.

From Specfork/crm at 80f0724, licence CC-BY-4.0.