# CONTRACT.md — Orbit

What this product does. Every clause has a stable ID and at least one tagged test; `pnpm verify` reports each as satisfied, overridden, or broken. Security, privacy, and operational guarantees are in `BASELINE.md`; a clause here may supersede one of them by naming its `BASE-` ID (SIGNAL-009 does). Capacity and rate limits are in `seed.json → limits`. Times are p95 at those limits. Clauses marked *Policy* have a default that buyers may replace through the named extension point.

## Scope

A CRM for one business and one revenue team, organised around what to do next. Customer signals arrive from the tools the business already runs, link to contacts, companies, and opportunities, and produce an explainable score and one recommended action. AI recommends and drafts; an operator reviews and sends every customer message. Orbit serves no tracking script and contacts no customer on its own.

## Operators and workspace

- **TEAM-001 — Equal operators.** Every operator can view and act on every contact, company, opportunity, signal, activity, and notification. There are no roles and no private records.
- **TEAM-002 — Management.** Operators are invited and removed in settings. The operator configured at setup can be removed only by themselves. *Policy: `operator.management.v1`; default: any operator may invite or remove.*
- **TEAM-003 — One workspace.** A deployment holds exactly one workspace and offers no way to create a second. Its name, timezone, currency, working hours, stale interval, awaiting-response interval, pipeline stages, signal sources, and free-mail domain list are configured in settings and apply to every record.
- **TEAM-004 — Removal handover.** Removing an operator revokes their sessions, cancels their scheduled messages, and unassigns their open opportunities. Their notes, drafts, sent messages, and timeline entries remain and stay attributed to them.

## Customer records

- **RECORD-001 — Core records.** Operators can create, edit, and delete contacts, companies, and opportunities. A contact requires a name or an email address and may also hold a phone number, job title, and timezone; a company requires a name and may hold one primary domain; an opportunity requires a title and holds a pipeline stage, a value in the workspace currency, zero or one owner, an expected close date, at most one company, and any number of contacts. Each entity listed in `seed.json → customFieldEntities` also holds custom fields.
- **RECORD-002 — Associations.** A contact belongs to at most one company and to any number of opportunities. An opportunity with at least one contact names one of them primary, and that contact is the default recipient for its outreach; an opportunity with no contact is never recommended an email follow-up. Changing an association moves no timeline entry: each record keeps the entries it already had.
- **RECORD-003 — Identity keys.** A contact's key is its email address lowercased with surrounding whitespace removed; a company's key is its primary domain lowercased with a leading `www.` removed. A domain on the configured free-mail list is never a company key. Creating or changing a record to a key another record already holds returns 409 with that record's ID and writes nothing. A record with no key is created and edited normally, and signals never link to it automatically. Orbit never merges records by name similarity, fuzzy match, or score. *Policy: `record.identity.v1`; default: the rules in this clause.*
- **RECORD-004 — Duplicate merge.** Merging two contacts or two companies requires an operator to choose the surviving record and the value of every conflicting field. The merge moves associations and timeline entries once, retires the other record's key as an alias so later signals carrying it link to the survivor, creates no signal, recommendation, or outreach, and records the retired ID in the audit trail.
- **RECORD-005 — Timeline.** A record's timeline lists its signals, stage and owner changes, notes, logged calls and meetings, recommendations, drafts, outbound attempts, replies, and merges, most recent occurred time first with ties broken by ID. Every entry shows its occurred time, its recorded time, and the operator, source, or feature that created it.
- **RECORD-006 — Search and views.** Operators can search contacts, companies, opportunities, and timeline text, and can filter opportunities by state, stage, owner, band, and queue. A first page of at most 50 matches returns within 500ms.
- **RECORD-007 — Ownership.** An opportunity has zero or one owner. Any operator can assign or reassign it, and the change appears on its timeline. *Policy: `opportunity.assignment.v1`; default: a new opportunity is unassigned.*
- **RECORD-008 — Import.** Operators import contacts and companies from a CSV, mapping its columns to fields once per import. A row updates an existing record only when it carries an Orbit ID or matches exactly one key under RECORD-003; a row matching more than one record fails. The import reports created, updated, and failed rows with row numbers and reasons, and writes nothing for a failed row.
- **RECORD-009 — Duplicate candidates.** Orbit lists contacts sharing a name within one company, and companies sharing a name, as duplicate candidates; names are compared lowercased with runs of whitespace collapsed. A candidate is a review item only: nothing is merged, altered, or hidden until an operator completes RECORD-004.
- **RECORD-010 — Manual entries.** An operator can add a note to any record and log a call or meeting with an occurred time, duration, and attending contacts. A manual entry is attributed to its author, editable and deletable by any operator, never sent to a customer, and never contributes to a score.
- **RECORD-011 — Related records.** A company page lists its contacts and its opportunities with each opportunity's stage, owner, and value, plus the total value of its open opportunities. A contact page lists their opportunities. Both reflect an association change on the next load.
- **RECORD-012 — Contactability.** A contact is contactable or do-not-contact. An operator can set do-not-contact at any time, and an unsubscribe event or a permanent bounce also sets it. No operation sends customer email to a do-not-contact contact, the state is shown wherever the contact appears, and only an operator can clear it.
- **RECORD-013 — Deletion.** Deleting a company detaches its contacts and opportunities and deletes neither. Deleting an opportunity deletes its recommendations, drafts, and unsent scheduled messages, and leaves its contacts, its company, and their entries for messages already sent. Deleting a contact follows DATA-002. Each deletion is confirmed before it runs.

## Signals

- **SIGNAL-001 — Ingestion.** A valid event from a configured source, carrying that source's signature, becomes one normalised signal within 10 seconds. The signal keeps its source, source event ID, type, subject, occurred time, received time, payload hash, and linking basis, and is never edited afterwards.
- **SIGNAL-002 — Signal types.** Orbit accepts form submissions; email sent, opened, replied, bounced, and unsubscribed events; meeting viewed, booked, completed, and cancelled events; page views; chats; content downloads; and firmographic changes. A source can add a type namespaced to itself, which changes no existing type. An event whose type is neither standard nor namespaced is rejected with 400.
- **SIGNAL-003 — Idempotency.** Repeated or concurrent delivery of one `(source, source event ID)` produces one signal and one set of downstream evaluations. Reusing that identity with a different payload hash is rejected and recorded as an error on that source.
- **SIGNAL-004 — Linking.** A signal links first by an Orbit record ID the source supplies, then by RECORD-003. A uniquely linked signal appears once on that record's timeline. A signal with no unique match is held in the unlinked queue, where it changes no score; an operator linking it later appends it to the chosen record's timeline exactly once and triggers one evaluation.
- **SIGNAL-005 — Event time.** A late or out-of-order event keeps its occurred time, appears in that position on the timeline, and triggers a fresh evaluation. It never overwrites a record field that a later-occurring event wrote.
- **SIGNAL-006 — Evidence confidence.** Every signal carries one confidence: observed, when the customer acted deliberately (form submission, reply, booking, meeting, download, chat), or inferred, when a tool reported activity (email open, page view). An inferred signal alone cannot create an opportunity, produce a high band, or satisfy ACTION-001's evidence requirement.
- **SIGNAL-007 — No tracking.** Orbit serves no tracking script, pixel, or cookie, and performs no fingerprinting or cross-site collection. A page-view, chat, or download signal is accepted only when its source supplies the visitor identity it already holds and the basis for holding it; without that basis the event is held unlinked under SIGNAL-004 and never becomes contact history.
- **SIGNAL-008 — Ingestion failure.** A missing or invalid signature returns 401, a request naming a source this deployment has not configured returns 403, and a payload over `seed.json → limits.signalPayloadBytes` returns 413; none of them writes anything. A persistence failure returns 503 and writes no partial signal, timeline entry, or evaluation, so the source can safely replay the same event identity. Each source shows its last accepted event, its last error, and its rejected-event count in settings.
- **SIGNAL-009 — Signed sources.** This clause supersedes BASE-ACCESS-004 for the paths `/api/v1/signals/` and `/api/v1/mail/events/` only: a request there is authorised by a signature from a source or mail provider configured in this deployment instead of an operator session, and may write only signals, delivery states, and their timeline entries. Every other mutation still requires a valid operator session and a server-side ownership check.

## Opportunities and priority

- **OPP-001 — Lifecycle.** An opportunity is open, won, or lost. Closing it as won or lost records the operator, the time, and a reason, and removes it from NOW and NEXT. Later signals append to its timeline and never reopen it; only an operator can reopen it, which returns it to open without changing its stage or owner.
- **OPP-002 — Independent state.** Stage, owner, score, band, and queue state are independent fields. A score or band change never changes stage, owner, or won/lost state, and a stage change never changes a score.
- **OPP-003 — Evaluation.** A linked signal whose type carries a non-zero weight in the scoring policy, or a change to an open opportunity's stage, expected close date, value, owner, or associations, evaluates that opportunity within 30 seconds. The result records a 0–100 score, a low, medium, or high band, the evaluation time, the policy version, and each contributing signal's contribution. A closed opportunity is not evaluated. *Policy: `opportunity.scoring.v1`; default: each contributing signal's configured weight decays linearly to zero over the 30 days after its occurred time, the sum is clamped to 0–100, and 0–39 is low, 40–69 is medium, 70–100 is high.*
- **OPP-004 — Explainability.** The opportunity view shows every signal the current score used, by ID, with its source, occurred time, confidence, and positive or negative contribution, and shows the reason for the current recommendation beside them.
- **OPP-005 — Evaluation failure.** If an evaluation fails or returns output that does not validate, the previous score, band, and evidence remain readable and are marked stale with the time of the failed attempt. Orbit shows no score, band, or recommendation it did not compute.
- **OPP-006 — NOW ordering.** NOW lists the open opportunities that OPP-007 placed there, ordered by score descending, then by the occurred time of their most recent contributing signal descending, then by ID. Operators can filter it by owner, stage, and band. *Policy: `opportunity.prioritization.v1`; default: the ordering in this clause, and every factor it uses is shown in the opportunity view.*
- **OPP-007 — Queue entry.** An open opportunity enters NOW when its band rises, a customer reply or delivery problem arrives, a scheduled message cannot be sent, its expected close date passes, its last timeline entry becomes older than the stale interval, or an operator puts it there. It is in NEXT while it waits for a scheduled send or for a customer response inside the awaiting-response interval, and in neither when no action is pending. The stale interval defaults to 14 days and the awaiting-response interval to 5 working days, counted Monday to Friday in the workspace timezone. Every entry, move, and removal records its reason and actor on the timeline.
- **OPP-008 — Manual completion.** An operator can complete or skip the current recommended action without sending anything, with an optional note; the outcome, note, and operator appear on the timeline. *Policy: `opportunity.next-action.v1`; default: evaluate once, keep the opportunity in NOW if that produces a new recommendation, and otherwise remove it from NOW and NEXT.*
- **OPP-009 — Pipeline stages.** The deployment has one pipeline. Its stages are named and ordered in settings; an opportunity can move to any stage in any order, and each move records the previous stage, the actor, and the time. A stage holding opportunities cannot be deleted until they are moved.

## Recommendations and drafts

- **ACTION-001 — Current recommendation.** An open opportunity has at most one current recommended action, which is either an email follow-up or a manual action. It names the action, cites at least one observed signal by ID, and records the policy or model version that produced it. Its written explanation is generated under AI-001 and is absent when AI-003 applies.
- **ACTION-002 — Replacement.** A new contributing signal, a changed opportunity, a completed or skipped action, or an operator request replaces the current recommendation. The replaced one stays on the timeline marked superseded and can no longer be drafted from, sent, or completed.
- **ACTION-003 — Draft on request.** An operator can request a one-to-one email draft for the current recommendation. Generation reads the opportunity, the primary or operator-selected contact, that contact's company, the cited signals, the messages already exchanged with that contact, and the configured instructions, and sends nothing. *Policy: `outreach.instructions.v1`; default: at most 150 words, and no fact, price, discount, or commitment that a cited record or signal field does not carry.*
- **ACTION-004 — Durable draft.** Recipient, subject, and body are visible and editable, and the draft is stored with its author, source recommendation, created time, and last edit time, and is readable after signing out and back in.
- **ACTION-005 — Stale draft.** A reply, meeting, unsubscribe, do-not-contact, permanent bounce, changed recipient, changed opportunity, or replaced recommendation marks a draft stale. A stale draft is shown as stale, and sending it returns 409 and writes no outbound message until an operator opens it, sees what changed, and confirms.

## One-to-one outreach

- **OUT-001 — Human send boundary.** No signal, score, recommendation, draft, job, or AI result can approve or start a customer message. A send requires an authenticated operator who has seen the exact recipient, subject, and body to invoke it, and a worker may dispatch only the unchanged content an operator approved for the schedule in OUT-009. No policy, adapter, extension, or setting removes this requirement.
- **OUT-002 — Message states.** Customer mail is submitted through `mail.sender.v1`. An outbound message is draft, scheduled, queued, reconciling, accepted, delivered, bounced, failed, cancelled, or delivery-unknown. The operator view calls a message sent only after the provider accepts it, and delivered only after a delivery event.
- **OUT-003 — Send idempotency.** Repeated or concurrent use of one send idempotency key creates one outbound message and at most one provider submission.
- **OUT-004 — Threading.** An outbound message carries message and reference headers that return the customer's reply to the same opportunity. A reply carrying those headers is attached once; a reply without them becomes an unlinked activity for operator review rather than a subject-based guess.
- **OUT-005 — Accepted outcome.** When the provider accepts a submission, one local transaction records the reviewed content and provider message ID as a timeline activity, completes the recommendation, sets the next action to awaiting response, moves the opportunity from NOW to NEXT, and creates one notification for the responsible operator. A replayed provider event repeats none of it.
- **OUT-006 — Delivery failure.** A transient submission failure retries up to 5 times over 1 hour. A permanent failure or an exhausted retry returns the opportunity to NOW, notifies the responsible operator once, and shows the provider's reason on the message. A permanent bounce also sets the contact do-not-contact under RECORD-012.
- **OUT-007 — Stop conditions.** A reply, a booked meeting, an unsubscribe, a do-not-contact, or a permanent bounce cancels that contact's scheduled messages and marks their unsent drafts stale under ACTION-005. A reply or a bounce ends awaiting response and returns the opportunity to NOW with the reply or the delivery problem as its next action.
- **OUT-008 — Quiet hours.** A draft can be written at any time. Sending outside 08:00–18:00 Monday to Friday in the contact's timezone, or the workspace timezone when the contact has none, requires a second confirmation naming the recipient's local time; a scheduled send waits for the next permitted minute. *Policy: `outreach.quiet-hours.v1`; default: the window in this clause, with no holiday calendar.*
- **OUT-009 — Scheduled send.** An operator can schedule one reviewed message for a future time and cancel it until it dispatches. Dispatch re-checks ACTION-005, RECORD-012, OUT-007, OUT-008, and OUT-010 against the current record and submits only the approved content; a stale or prohibited message is not sent, returns the opportunity to NOW, and shows the reason.
- **OUT-010 — Sending limits.** The per-minute deployment limit and the per-operator rolling-day limit in `seed.json → limits` are checked before submission. A message over a limit stays unsent and visible with the limit that stopped it and the time it may be sent; Orbit neither retries it silently nor merges it with another message.
- **OUT-011 — Unresolved submission.** Orbit records the outbound intent and its provider idempotency key durably before submitting. If the transaction in OUT-005 fails the message becomes reconciling; if the provider's response is a timeout it becomes delivery-unknown. In both states a job queries the provider by that idempotency key every 5 minutes for up to 24 hours, submits nothing again, and applies the OUT-005 outcome exactly once if acceptance is confirmed. A message still unresolved after 24 hours becomes a failure the operator can see, and notifies the responsible operator.
- **OUT-012 — Unsubscribe.** Every outbound customer message carries `List-Unsubscribe` and `List-Unsubscribe-Post` headers handled by the mail provider. An unsubscribe event from the provider sets that contact do-not-contact under RECORD-012 within 60 seconds and cancels their scheduled messages.

## AI

- **AI-001 — Provider and input.** Recommendation explanations and email drafts go through `ai.provider.v1`. The request preview shows the operator every field, timeline entry, and instruction the request will carry, and nothing outside the preview is sent.
- **AI-002 — Output validation.** AI output must validate against its operation's schema and cite only evidence IDs present on the record. Output failing either check fails visibly and is never repaired with invented content or a semantic fallback. Only a transient transport failure is retried, at most twice within 30 seconds.
- **AI-003 — Provider failure.** When the AI provider is unconfigured or unavailable, records, signal ingestion, linking, scoring, queue placement, search, manual updates, hand-written drafts, and sending all continue to work, and a recommendation still names its action and evidence. A generation request shows the failure and produces no explanation or draft.
- **AI-004 — Cost.** Every AI call records provider, model, input and output tokens, feature, duration, status, and estimated cost. Operators can view totals by day and by feature in settings.

## Notifications

- **NOTIFY-001 — Responsible operator.** The responsible operator for an opportunity is its owner, or, when it is unassigned, the operator whose action appears most recently on its timeline. They receive one in-app notification when it enters NOW, when a customer reply arrives on it, and when one of its outbound messages fails, bounces, or becomes delivery-unknown. When an unassigned opportunity that no operator has acted on enters NOW, every operator is notified instead. Duplicate source or provider events add no notification.
- **NOTIFY-002 — Read state.** A notification is unread until the operator opens it or marks it read. The unread count updates within 5 seconds and never counts one event twice.

## Public surfaces

- **PUBLIC-001 — Landing page.** `GET /` returns 200 without an operator session, takes its name, description, and images from `ext/config.ts`, and links to magic-link sign-in. It shows no contact, company, opportunity, or signal from this deployment.
- **PUBLIC-002 — Safe demo.** When enabled in `ext/config.ts` and configured with the explicit `PUBLIC_DEMO_OPPORTUNITY_ID` binding, `GET /demo/` loads one deliberately published opportunity from the dedicated hosted demo deployment after the visitor starts it. The opportunity and its observed evidence, recommendation, and publish-safe draft are created through Orbit's real operator workflow; no fixture or runtime mock is used. The visitor can edit and preview the follow-up entirely in the browser. The page states before loading and after preview that nothing is sent and no record changes. The browser makes no write request and calls no mail adapter, AI adapter, signal endpoint, or notification. The public endpoint cannot enumerate or select another record, and omits private addresses and raw signal payloads. Turning it off in `ext/config.ts`, or omitting the explicit binding, makes `/demo` and `/demo/` return 404. This wording records the owner-authorized 13 September 2026 ruling that replaced the earlier browser-generated sample method while preserving its safety purpose.
- **PUBLIC-003 — Unconfigured sign-in.** While no mail-auth adapter is configured, `GET /signin` returns 200 without an operator session and states that magic-link delivery is unavailable. It presents no credential field, demo account, local-login action, or session bypass.

- **PUBLIC-004 — Native sign-in.** Configured magic-link delivery sends only to an existing active operator, gives the same request response for unknown and inactive addresses, and exchanges a hashed, single-use link within 15 minutes for a secure native session. Return locations stay inside the operator app. Sign-out revokes that session. A missing or failed mail configuration cannot issue a session.

## Data

- **DATA-001 — Export contents.** The export contains contacts, companies, opportunities, associations, source configuration without secrets, signals, evaluations with their policy versions, recommendations, drafts, outbound messages with their provider statuses, timeline activities, notifications, settings, and audit entries as JSON.
- **DATA-002 — Contact erasure.** Within 5 minutes of an operator deleting a contact, their email addresses, signal payloads, drafts, message bodies, and the notifications naming them are absent from records, search results, exports, and every later AI or mail request. Only audit entries survive, with the contact's reference anonymised, as BASE-DATA-003 requires.

## Out of scope

Declared in `seed.json` as non-goals and not tested: a second workspace; roles, territories, and private records; a second pipeline; a workflow builder; bulk campaigns and sequences; autonomous outreach; SMS, phone, and social sending; attachments on outbound messages; email and digest notifications; a tracking script, cross-site tracking, fingerprinting, and data-broker identity resolution; hosted forms and landing-page building; marketing CMS, ads, and attribution; per-record currencies; plan and pricing pages; revenue forecasting; subscription billing and plan enforcement; and native mobile apps.
