@specfork/crm
Customizable CRM with companies, people, deals, custom objects, views, notes, tasks, import, workflows and mail sync.
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 | 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_casenames (the source used camelCase) to match other Specfork specs.- Delete only after deactivation, a safety step the source didn't have.
- Enabling
uniquereports 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;numberwithkindcovers 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
wonandlost; 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/teamsinstead 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.