Browse this reference

Atlas REST reference

Call governed Atlas actions from an HTTP client. Requests respect the token holder’s organisation and permissions; write proposals require human confirmation.

Request a sign-in code

Public POST /auth/send-otp accepts {email, invite?} and applies the existing address-admission and invitation checks before issuing a code. An accepted request returns HTTP 200 with sent, delivered, delivery, cooldownSeconds and codeLength. Both booleans are true only for provider_accepted: the mail provider accepted the request, which does not prove inbox delivery. test_passcode means the configured staging universal or scoped UAT passcode is active; no email is dispatched and both booleans are false. Use the configured test passcode; this response never reveals it. not_configured and provider_error also return false for both booleans. For provider_error, provider acceptance may be unknown after a network failure; false does not prove the email was not sent. Outside production, a generated code whose acceptance was not confirmed may be returned as devCode; production never returns it. HTTP 200 permits entering the code; clients must not require sent:true to continue. Cooldowns and POST /auth/verify-otp remain unchanged. Missing email returns 400, denied admission 403, and cooldown/rate limiting 429 with Retry-After. A skipped test email is not evidence of a provider outage.

First request: find a customer

Before you begin: an HTTP client, a staging account, and a delegated token with crm:read. Obtain a token using the MCP authentication instructions and set it as ATLAS_ACCESS_TOKEN in your local shell; keep the actual token out of documentation and logs.

  1. Replace Acme with the name of a customer you can access. The URL explicitly targets staging and makes no production-availability claim.
  2. Run the read-only search below. POST is the protocol for this action; it does not imply a customer mutation.
curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/crm.customers.search \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"q":"Acme","limit":5}'

Check the result: The response uses the atlas.action.v1 envelope. On success, status is completed; inspect the matches in data and retain request_id for diagnosis. An empty match list is valid and reflects only data visible to the token.

Recovery and next steps

  • Authentication failure: check for a missing, expired or revoked token and confirm the target environment. For insufficient access, check the scope named in the error and the organisation permission.
  • No matches: check the customer name, filters and token’s organisation before creating a duplicate.
  • For more actions, see the CLI-to-REST mapping. Before a write, read proposal review and confirmation; HTTP 202 means awaiting confirmation, not a completed change.

How to use this reference

For each action, check method, path, authority, input, result and errors. Interfaces explicitly marked internal or using /internal/ below are Worker Service Binding interfaces, not public HTTP entry points; the delegated-token example here cannot call them.

Use your browser’s find function to locate an exact action name or path. See the user manual for web workflows and the MCP guide for client connections.

Fictional Skill quality run

POST /knowledge/skill-quality/run is available only to a live platform-superadmin session. It executes one compiled fictional DOCX, PPTX, XLSX or PDF fixture through its real owner runtime and model, then seals the independent expected checks in Governance. The JSON body is {"fixtureId","batchId","repeatId","skill":{"skillId","version","digest"}}. The selected version and digest must be the compiled executable identity; a retry with the same fixture, batch and repeat returns the same sealed result and never runs a second model call.

Responses report a sealed result or one named failure: extraction_failed, report_unavailable, execution_in_progress, or input_mismatch. These fixtures are fictional diagnostic inputs. They do not discover documents, change a Library record, or make a production-quality claim.

Read your private Tencent Meeting records

Connect your own personal/pro account, read records Tencent permits, and review a selected excerpt before saving a customer note or a private task. This reference describes the implemented contract; deployment and actual-provider acceptance are separate release gates.

Connect and read

  1. Sign in to Atlas as an active member of an organisation with the personal account model enabled. Open Superpower → Plugins → Tencent Meeting.
  2. Choose Connect account. Atlas opens Tencent Meeting’s authorization page. Sign in there and approve access, then return to Atlas. If the page did not open, use Open Tencent Meeting to authorize in the waiting dialog. Approval expires after five minutes; retry starts a fresh attempt. Cancel stops the attempt unless authorization already completed. Choose Reconnect account to replace access for an existing connection.
  3. Select the account and a read action, fill the discovered fields, and read the result. Keep opaque IDs as strings. When a result has has_more:true and next_page_token, use the returned value as page_token for the next page. A failed or oversized read is refused, never presented as an empty successful sync.
  4. Select an exact source excerpt and a customer. Open the note or task review, edit your interpretation, and check the destination before saving. Notes become visible under that customer's normal access rules. Tasks are private and assigned to you. Each save needs its own explicit confirmation; Cancel saves nothing.

These are signed-in browser-session routes. The current owner, active organisation account model and live credential are checked for every provider read and source-backed save. Other members, administrators and support cannot use your account. Bearer-token MCP/REST clients and background jobs receive no Tencent tools or content. The bearer examples elsewhere on this page do not apply here.

Session API

POST /crm/tencent-meeting/authorization/start
Send {} for a new account, or {connection_id,expected_version} to reconnect an owned account. Returns {attempt_id,authorize_url,expires_at}; expires_at is Unix milliseconds. Open the returned official Tencent URL; never reuse a sample authorization code.
POST /crm/tencent-meeting/authorization/:attempt_id/poll
POST /crm/tencent-meeting/authorization/:attempt_id/cancel
Send {} from the initiating Atlas session. Poll no faster than every five seconds. Returns status: pending, connecting, connected, expired, cancelled, denied or failed; connected responses include connection_id. Attempts expire after five minutes and a retry invalidates the previous attempt. Responses are private, no-store. Provider credentials stay encrypted on the server. Cancellation and completion have one winner; repeated completion does not create another account.
POST /crm/integrations/tencent_meeting/connect
Legacy private-token compatibility only; new browser connections use authorization above. account_kind:"personal", fields:{account_name,private_token}. Reconnect adds connection_id and the current expected_version; the owner comes from the session. Success is 201 with connection and account, never the token.
GET /crm/tencent-meeting/connections
{items,can_connect}: only your own Tencent accounts, with id, label, status and config_version. No accessible accounts returns an empty items list.
GET /crm/tencent-meeting/connections/:id/tools
{items}: current allowed read actions and their input_schema. Use discovery for arguments rather than hard-coding them. Missing, unsupported or changed schemas prevent calls.
POST /crm/tencent-meeting/connections/:id/read
{tool,arguments}. Success includes result_text, source_connection_ids and source_review:{receipt,source_text,provenance}. Source text is preserved, with an 8,000-character limit; an over-limit reply fails whole.
POST /crm/communications/connections/:id/pause
POST /crm/communications/connections/:id/resume
POST /crm/communications/connections/:id/disconnect
Send the current expected_version. Pause stops reads; disconnect stops account access. After a lifecycle change, reconnect or automatic credential refresh, read again before saving from an older source receipt. Token replacement refuses old in-flight reads and receipts; new reads use the replacement credential. Notes and tasks you already confirmed remain in their destinations.
GET /agent/assistant/threads/:id/private-source-status
For a thread the caller owns and can still read, {review_only:true} means it has Tencent-derived sources; review_only:false grants no additional permission. Thread-linked generic work-item and personal-memory saves refuse with 409 private_source_review_required; use the dedicated reviewed note/task flow. Unreadable threads return 404.

Review the source before publishing

Keep the returned receipt and complete source_text unchanged. Submit a non-empty excerpt that occurs exactly inside that text. The signed receipt expires after 30 minutes and binds the organisation, owner, connection, configuration/credential versions, tool, provider identifiers, retrieval time and text digest. It proves what Atlas retrieved at that time; it is not provider-certified evidence or a promise that Tencent permissions remain unchanged. provenance.source_kind:"provider_generated_summary" identifies smart minutes; your reviewed interpretation remains separate from the selected source.

Request examples

Illustrative payloads below use placeholders. Replace them with your current discovery result and selected source; use the signed-in same-origin browser session. The first request goes to the read route.

{"tool":"get_meeting","arguments":{"meeting_id":"<opaque meeting ID>"}}

Send the note payload to POST /crm/customers/:id/facts with a separate x-atlas-idempotency-key header. The customer is the reviewed destination.

{"body":"<reviewed interpretation>","category":"other","tencent_source":{"receipt":"<receipt>","source_text":"Available next week.","excerpt":"Available next week."}}

Send the task payload to POST /tasks. due_at is epoch milliseconds or null; use your own email and a separate review_request_id of 16–128 letters, digits, underscores or hyphens. Do not supply source_id, metadata, another assignee or team visibility.

{"customer_id":"<customer ID>","title":"<reviewed task title>","description":"<reviewed interpretation>","due_at":null,"assignee_email":"<signed-in email>","visibility":"private","review_request_id":"review_task_example_01","tencent_source":{"receipt":"<receipt>","source_text":"Available next week.","excerpt":"Available next week."}}

Both save paths revalidate current source and destination access. A new save returns 201; an identical retry with the same operation key returns the original record with 200 and replayed:true. Reusing that key for changed reviewed fields or another customer returns 409 idempotency_conflict. Keep the original payload for a retry; use a new key only for a separately reviewed action. A revoked source may refuse even an otherwise identical retry.

Limits and recovery

Only these fixed reads are available: get_user_meetings, get_user_ended_meetings, get_meeting, get_meeting_by_code, search_meetings, get_records_list, search_records, get_transcripts_details, get_transcripts_paragraphs, search_transcripts, get_smart_minutes. Recording access still depends on Tencent; being a participant is insufficient. No scheduling, meeting changes, invitations, permission requests, downloads or automatic Knowledge publication are supported. Recording passwords and account credentials are refused as tool arguments; page_token remains a supported pagination cursor.

  • 409 personal_accounts_not_enabled: your organisation's personal account model must be enabled before setup. Support cannot create a dormant Tencent account for you.
  • credential_rejected, paused or disconnected account: reconnect your own account through Tencent’s authorization page, or resume an eligible paused account.
  • tool_reclassification_required: reconnect to verify and pin current schemas; administrator classification cannot widen the read allowlist.
  • invalid_source, source_unavailable or an expired receipt: read the source again and review it before a new save. A missing recording, denied access, processing state, timeout or provider error is a read failure; Atlas does not request permission from participants.
  • response_too_large: narrow the request or choose a smaller page. Atlas does not silently truncate a page and call it complete.

POST /internal/integrations/tencent-source is CRM's signed Service Binding validation endpoint for Tasks, not a public REST endpoint. Its exact body is {org_id,actor:{principal,expires_at},source:{receipt,source_text,excerpt}}; the verified actor keeps the original session expiry, and its organisation must match org_id. The Tencent token stays in CRM; no raw token is handed to Agent, Tasks or an MCP client.

REST without the CLI

Internal mailbox authority

POST /internal/integrations/authority is an Auth-owned Service Binding interface for CRM, not a public REST endpoint. Each body-signed request binds the verified initiating principal, original credential expiry and one account-authority operation. Auth checks current session or delegated-token validity, active organisation membership and current permissions, then returns its existing opaque user identity. Missing or unverifiable authority is refused; caller-supplied owner, organisation, support flags and action lists never grant access.

CRM owns account policy and returns allowed_actions to its internal consumers. Agent uses the body-signed CRM Service Binding endpoints POST /internal/integrations/outbound-credential and POST /internal/integrations/account-effect-versioned to obtain only a current verified SMTP half and pin its credential version immediately before a socket opens; the legacy POST /internal/integrations/account-effect remains a compatibility check. These interfaces are unavailable through public routing, return no-store responses, and never expose inbound settings, envelope JSON or hints. Deploy CRM before Agent; after the new Agent is live, remove COMM_CREDENTIAL_KEY from the Agent Worker because credential decryption remains CRM-only. Setup support does not confer message or send authority. Mailbox effects recheck current authority immediately before work; revocation prevents subsequent work, while an already-started provider effect retains its recorded outcome. Member enumeration is limited to the authenticated grant-migration operation. Legacy scheduled sync without a verified initiating user is refused before provider access; existing messages and cursors are retained. Personal-account creation and named-grant activation remain inactive pending their separately accepted rollout stories (#2823).

Internal policy evaluation

Category evaluation (#3733). POST /internal/policy/evaluate is an EKB-owned Service Binding interface for Atlas services, not a public REST endpoint. Each request is internal-HMAC body-signed and carries {principal, category, facts}: category is one of task-creation, record-change, draft-preparation, external-communication, administration; facts holds only the typed keys actor_role and customer_class, each a string or an explicit null for a known-absent fact. The organisation is taken solely from the signed principal — no request field can select a tenant. Evaluation covers every published policy release of that organisation (a withdrawn release is excluded), the strongest outcome wins (none < warn < block), and the answer returns every matching reason with its policy title, section heading and a quote of at most 200 characters, plus the exact policy_set_revision. When an applicable rule needs a fact the request did not supply, the outcome is could_not_verify naming the missing fact — never a silent none; a fact supplied as null matches no predicate and does not become unverifiable. Rule writes (PUT /knowledge/policies/:id/rules) require the Knowledge-rules capability; the schema-2 preview (POST /knowledge/policies/:id/rules/preview with {base_version, rules}) reports what the rules would have done to the organisation's assistant proposals from the last 30 days without creating any effect or release. A schema-2 rule may carry an optional permitted_alternative — what a rep may do instead of the thing the rule blocks — of at most 300 characters; a write whose permitted_alternative is longer or empty is refused with 400, and every matching reason carries the sentence verbatim as permitted_alternative (#3954). A section may carry a Simplified Chinese title and body beside the English pair (title_zh, body_zh); when a matched section has a Chinese title the reason returns it as heading_zh. Both extra fields are omitted — never empty — when absent, so existing policies evaluate to byte-identical reasons.

Evaluation campaign model route

Route trial (STORY-014). POST /governance/eval/campaigns is a platform-operator repository surface on staging, not a delegated-token endpoint. Its JSON body may carry an optional modelRoute of glm_only or deepseek_only. Before any world exists, any other value is refused with 400 model_route_invalid, and repeating an idempotencyKey with a different route (or with the route added or removed) is refused with 409 idempotency_conflict. With a route, every trial world's own evaluation organisation is set to that Atlas-managed preset before seeding and before its first Assistant run; tenant organisation policies are never written. A preset takes no fallback: when the route's platform credential is missing, the trial fails with the gateway's model_route_unavailable reason instead of running on another model, and every call stays governed and metered. The report's identity.requestedModelRoute names the route and identity.routeMismatchTrialIds lists trials whose observed trace model differs from bigmodel/glm-5.3-flash or deepseek/deepseek-v4-flash respectively; the summary prints both. A failed trial still records the model each started run used, so it can appear in that list, and the report shows its recorded detail, such as run ended failed: model_route_unavailable. Without modelRoute the campaign, its stored inventory and its report are unchanged, and the comparison compatibility key is the same in both cases.

Evaluation campaign feature preview

Feature preview (STORY-167). POST /governance/eval/campaigns may also carry an optional featurePreview: a non-empty list of registered feature keys, each once, for example ["assistant-plan-prefix"]. Any other value, including an unregistered key, is refused with 400 feature_preview_invalid before any admission or world exists, and repeating an idempotencyKey with a different list is refused with 409 idempotency_conflict. With a list, each trial world's own evaluation organisation gets an enabled per-organisation override for every listed key before seeding and before its first Assistant run, written through the same audited override as Platform → Feature visibility; tenant organisations are never written. The overrides are removed at world cleanup, and a trial whose override cannot be written fails rather than running without it. The campaign record and its stored inventory carry featurePreview; the inventory digest and the report's comparison compatibility key do not change, so a campaign with the switch on can be compared with one without it.

Evaluation model route comparison

Route trial comparison (STORY-123). GET /governance/eval/reports/:id/compare-route?to=:id is a platform-operator repository surface on staging, not a delegated-token endpoint: any other caller receives 403, and production answers 404 before authentication. It compares two reports that differ only in requested model route. It applies every check of /compare except the model-identity key, and additionally requires identity.requestedModelRoute on both reports with different values, empty identity.routeMismatchTrialIds on both, each report's model identity equal to its route's model, and the same Worker identities in both campaigns. Otherwise the answer is {"comparison": {"compatible": false, "mismatches": [...]}} naming each field (for example requestedModelRoute, workers.agent, to.modelPolicy), with routes and cases set to null. A compatible pair returns routes.from and routes.to, each with passAt1 and passAllThree counts, agentMsPerTrial (p50/p90 of the trial's stored agent time), modelLatencyMsPerCall (p50/p90 of billed usage_events.latency_ms for every run the trial started, in the trial world's organisation) and modelCallsPerTrial; plus cases (pass@1 and pass^3 per case: fixed, regressed or unchanged), changedCaseIds and safetyRegressions (safety-* cases that regressed). A percentile or mean is null unless every trial has that evidence; coverage counts show how much was found. A started run with no billed usage_events row leaves its trial uncovered (unknown, never zero calls) unless its trace recorded no model. The answer reports; it never chooses a route. A missing to is 400 missing_to; an unknown report is 404 for a platform operator; any other caller gets 403 before either report is looked up. /compare is unchanged.

Assistant policy admission and overrides

Every assistant effect is admitted first (#3735). Before an assistant action is shown for confirmation, and again when it is confirmed, the Agent evaluates the organisation's published policies for the action's category with the actor's role and the customer's class (POST /internal/policy/evaluate). A customer with no class, or an action with no customer, is sent as known-absent and matches no class rule. The result rides on the action envelope as policy — {outcome, category, reasons, policy_set_revision}, where outcome is none, warn, block or could_not_verify and each reason carries the policy title, section heading and a quote — on the proposal (POST /assistant/v1/actions/:id and GET /assistant/v1/proposals/:id), on every refusal, and on the confirmed outcome. The action's own data is unchanged. When the cited rule names a permitted alternative, or the cited section has a Chinese title, every reason also carries permitted_alternative and heading_zh, on the web block card, an MCP tool result and a CLI answer alike (#3954); both fields are omitted for policies that do not set them.

Confirming. POST /assistant/v1/proposals/:id/confirm takes an empty body or {"override_reason": "..."}. Evaluation accepts every principal the assistant confirms under, including a delegated token without tasks:write and a scheduled run. A block holds the proposal for an owner or admin decision. Other principals, including delegated tokens and scheduled runs, receive 403 owner_decision_required with the policy reasons and cannot proceed by resubmitting override_reason. A signed-in owner or admin can decide the held plan with a 10–500 character reason through POST /agent/assistant/owner-decisions/:id/decide; an approved confirmation records the override and its cited sections. could_not_verify (EKB or CRM unavailable, a timeout of 5 s, or a customer the organisation cannot read) is always refused with 503 policy_unverified and no effect; no reason lifts it. A warn goes ahead and is recorded on the outcome. If the result at confirm is stricter than the one shown, the confirm is refused with 409 policy_changed (“Guidance changed — review again”), and the proposal shows the new result and stays open. A refused confirm spends no idempotency key; a replay of a completed confirmation returns the recorded result, policy and override included, with no second effect. A scheduled run's override reason is refused with 403 override_not_allowed; an action with no policy category is refused with 403 policy_category_unknown.

The paths without proposals. POST /agent/assistant/brief/step/accept and, for a task_recommendation row, POST /agent/prepared/:id/approve (task creation), POST /agent/engagement-plan/draft and POST /agent/prepared/on-demand (draft preparation) accept an optional override_reason and answer with the same codes: 409 policy_blocked with policy until a reason is given, 503 policy_unverified, and on success the answer carries policy and, after an override, override. Sending an approved email draft the assistant prepared is admitted the same way (#3757): POST /agent/prepared/:id/send and POST /agent/prepared/:id/retry-send take an optional override_reason (on the retry, beside confirm_duplicate_risk and prior_command_id) and are admitted as external communication for the draft's customer, by the sending person, before anything leaves Atlas — same refusal codes, and a sent answer carries policy and, after an override, override beside outbox/command_id. A retry's own pre-checks (400 retry_confirm_required, 409 send_in_flight, 409 send_already_accepted, 409 retry_stale_prior) answer first with no policy check, and the single-slot claim is taken only after admission passes. A draft that cannot send at all — another kind, not approved, or no resolvable recipient — keeps today's refusal (404 not_found, 400 wrong_kind, 400 wrong_status, 400 no_recipient) and is never admitted. A task_recommendation is approved once: approving a row that is no longer pending answers 409 already_decided, and approving, editing (POST /agent/prepared/:id/edit) or dismissing (POST /agent/prepared/:id/dismiss) it while another approve of it is in flight answers 409 in_progress (try again in a moment) — with no policy check, no override and no task. The Tasks create is time-boxed at 30 s; a timeout answers 504 task_create_failed and leaves the row pending.

Policy overrides list. GET /agent/assistant/policy-overrides lists the organisation's overrides from every path, newest first — {items, next_cursor}, each item {id, at, actor_id, operation, proposal_id, subject_ref, customer_id, reason, sections, policy_set_revision}; pass ?cursor= for the next page (up to 100 per page with ?limit=). Admins and managers only; anyone else receives 200 {"items":[],"next_cursor":null}. The record is append-only. An override is recorded only for an attempt that got past every precondition: a refusal such as already_pending, not_found, already_decided, in_progress or a stale proposal records none, and a proposal confirmation records its override together with the completed outcome. On a prepared-draft send (#3757) the policy admission runs before the sender's own rate-limit and duplicate checks, so an override given for a send that then answers 429 rate_limited or already_sent is kept, and the eventual retry is admitted again. GET /crm/internal/customers/classes now also answers found per id, so an id the organisation cannot read is never mistaken for a customer with no class.

Policy decisions log and per-policy metrics (#3951)

Every admission evaluation now appends one row to the Agent-owned append-only agent_policy_decisions table (migration 0029_policy_decisions.sql): who, the principal kind (person, scheduled, mcp or cli), the operation and category, the customer and its class, the outcome (none, warn, block or could_not_verify), would_block (0 until warn-only releases), the cited policy and section ids with the policy-set revision they were evaluated under, and the source — rule for the evaluation itself, override for a granted override (a second row at the same instant, keyed by the override's id), permission reserved for permission-tier decisions. Database triggers refuse UPDATE and DELETE, matching assistant_policy_overrides.

Per-policy metrics. GET /agent/assistant/policy-metrics?policy_id=&period=week|month|quarter (owner or admin; anyone else receives 403 forbidden) returns {period:{start,end}, checked, warned, blocked, would_block, overridden, could_not_verify, coverage, overrides}. Counts cover the UTC calendar week (from Monday), month or quarter and read zeros when the period has no rows. checked, warned, blocked and would_block count evaluations whose cited policies include the requested policy; overridden counts its override rows; could_not_verify is the organisation's unverifiable checks in the period (an unverifiable check cites nothing). coverage lists every registered action category with the organisation's check volume in it and the number of distinct sections of this policy recorded in real evaluations there. overrides joins assistant_policy_overrides (newest first, max 50), each {id, at, actor_email, operation, section_id, reason}, kept to the overrides that cite this policy's sections.

Overrides spike. GET /agent/assistant/policy-metrics/overview (owner or admin) returns {overrides_week, overrides_prev_week, spike, scheduled_refusals_week} where spike is true when this week's overrides reach 3 or reach twice the previous week's count, and scheduled_refusals_week counts this UTC week's scheduled runs a policy refused — the block decisions whose principal is the scheduled sweep (#3965). The Business overview's "At risk and changing" row reads these same figures: when no scheduled draft was refused this week the row is omitted, never shown as a zero.

Assistant permission tiers on every admission (#3812)

Every admission-checked path also reads the organisation's stored assistant-permission tiers (Settings → Assistant permissions; migration 0027_assistant_permissions.sql) after the category is resolved and before the policy evaluation. Three baseline categories map one-to-one: draft preparation to work.prepare, task creation to task.create_followup, and external communication to the fixed With-permission external.send. An agent.team.change uses the category its work row resolves to; record-change and administration carry no tier yet. A never tier refuses every path — a person's confirmation, an auto-pilot hire's, and each proposal-less path — with 403 permission_never, regardless of the hire's permission level or the organisation's assistant switches; no override reason lifts it. A with_permission tier is refused to a scheduled run with 403 permission_needs_person (a delegated token counts as the person) and goes ahead for a person exactly as before. An organisation with no saved row reads the catalogue default tier. If the permissions table cannot be read — an unbound database, an error, a timeout, or the migration not yet applied — the action is refused with 503 permission_unverified and never allowed. A stored proposal confirmed after its tier changed refuses with permission_never, never policy_changed. Unattended preparation takes the stricter rule: the overnight sweep, the scheduled engagement preparation and the change-triggered POST /agent/internal/prepare-draft (which then answers skipped_permission) prepare only when the mapped tier is by_itself and the organisation's autonomous_prepared_work switch is on; a run of the AI team still files its work for review, but nobody — person or hire — can approve a Never action.

Routines: scheduled assistant tasks (#4262)

Management surface. GET /agent/routines lists the organisation's routines — each item carries id, name, task_kind, config, schedule, enabled, created_by, created_at, next_run_at and the latest run's outcome — and POST /agent/routines creates one with {name, task_kind, config:{label_set_id, auto_apply}, schedule:{kind:"daily"|"weekly", weekdays?, hour, minute, timezone}, enabled}. task_kind is customer_status_update or customer_attention (validated in code; later kinds need no migration); a status-update routine's label_set_id must name one of the organisation's company label sets (an unknown, private or protected-Industry id answers 400 invalid_request); timezone is an IANA zone. Owner/admin only through the routines permission resource: anyone without read receives 200 {"items":[]}, and every write refuses 403 forbidden. PATCH /agent/routines/:id accepts any of name, config, schedule, enabled (a schedule change recomputes next_run_at); DELETE /agent/routines/:id removes the configuration while past runs and their items stay readable (run history is never orphaned). POST /agent/routines/:id/run-now claims a manual run and answers 202 with the running row; while the routine already has a run in flight (a running row that has made progress within the last 20 minutes, the same test the stale sweep uses) it answers 409 already_running and starts nothing, so a double press starts one run. Each claimed run — manual or scheduled — executes once as its own Cloudflare Workflow instance, so it runs to the end rather than being cut off with the request; when the Workflow is unavailable the run falls back to background execution; when the Workflow cannot confirm whether the instance exists, the run is not executed and ends failed with error dispatch_uncertain, so run it again — unless the instance has already taken the run, in which case it finishes and records its own outcome. A run that cannot be read to start ends failed with error setup_read_failed, one whose earlier progress cannot be read with resume_read_failed, one whose executor cannot read whether it still owns the run with ownership_read_failed, one whose routine was deleted before it finished with routine_deleted, and one whose Workflow step throws or times out with workflow_step_failed; each is safe to run again. A run still running that has made no progress for 20 minutes and whose Workflow instance is no longer live is ended by the five-minute sweep with failed and error stale_run; nothing is replayed — no model call and no label change. A manual run's model calls are metered to the person who pressed Run now and count against that person's per-user limits. A run interrupted mid-flight keeps its recorded decisions; the Workflow step is not retried, and later scheduler ticks do not adopt or replay an uncertain running row (a stale one is only ended, as stale_run above); GET /agent/routines/:id/runs?limit=&before= pages the history (newest first, before cursor); GET /agent/routine-runs/:id returns {run, routine, items} with the per-customer decisions; POST /agent/routine-runs/:id/apply takes {"all":true} or {"item_ids":[seq, …]} and applies the named proposals manually — 409 nothing_to_apply when none remain; an attention-kind run answers 409 apply_not_supported — apply belongs to the status-update kind. The attention kind takes an empty config and creates one attributed task per attended customer, due the next local morning, through the Tasks pillar — see #4263.

Execution. The drain rides the existing five-minute tick: a routine is due when enabled and next_run_at <= now, at most ROUTINE_CAP_PER_ORG (default 2) routines per organisation per tick and ROUTINE_CUSTOMER_CAP (default 20) customers per run — the batch is the stalest-interaction-first slice of active customers, and the cap is recorded in the run summary. Exactly one scheduled run executes per routine per local day in its own timezone (day-keyed); a run that stops early on error or budget records partial (or failed on total error) and keeps its completed items, and a later drain the same local day starts nothing new. Each customer is evaluated under the organisation's published policy guidance (the same internal EKB resolver every admission path uses; a block records a keep with the policy cited and no model call), and one governed model call under the reserved scheduled principal agent:routines with purpose routine answers keep or change-to-value with a one-line reason. An empty organisation, an empty label set or a deleted label set completes the run with an explicit inert summary rather than an error.

Applying. The Agent never writes labels: proposals apply through CRM's body-signed internal route POST /crm/internal/assistant-label-changes ({org_id, routine_id, run_id, mode:auto|manual, applied_by_email, changes:[{seq, customer_id, value_id, reason}]}), which validates the HMAC and the org, resolves the organisation's stored customer.labels_by_routine tier over the AGENT binding and refuses the automatic path with 403 permission_required unless the tier is by_itself (503 permission_unverified when the tier cannot be read — fail closed); the manual mode ignores the tier because a person decided. Each applied change replaces the customer's value in that one set with assistant attribution (assistant:routine:<id>), writes a label_change customer-timeline row, and lands in Done-for-you with undo; change ids are deterministic (labchg:<run>:<seq>), so a retried apply replays instead of writing twice. Undo restores the recorded prior values and refuses 409 status_moved_on once a person has retyped the value.

Metering boundary. The existing agent:routines and agent:scheduled-sweep reserved org actors authenticate only signed, body-bound POST /governance/internal/credit/reservations and POST /governance/usage requests. The audience, REST surface, exact action, null scope, path without query, request identity and lifetime must match. Usage retains the signed org and actor and must own its reservation. These internal service-binding destinations add no browser, delegated-token or other scheduled-agent permission.

Attempt timing. A POST /governance/usage body may carry an optional timing object, {reserveMs, providerMs}: the milliseconds the attempt spent in its credit reservation and in the provider call (providerMs may be null). Governance stores it best-effort, after the response and keyed like the usage row; an absent or invalid value is ignored and never changes the usage record, its rating or its settlement.

Run timing. A POST /governance/runs body (the Agent's trace delivery) may carry an optional timing object with millisecond offsets from request arrival (firstModelCallMs, firstFrameMs, firstTextMs, endMs) and an optional setupMs: how long the Assistant turn's parallel setup reads took, a duration rather than an offset. Each measure must be a finite number from 0 to 86,400,000 and is rounded to whole milliseconds; a negative, non-numeric or out-of-range value is dropped and stored as empty, never rejecting the run. An omitted setupMs (a turn that never ran the setup reads) is stored as empty and left out of the setup percentiles; a later delivery of the same run replaces the earlier value. Governance stores timing best-effort beside the run; a failed timing write never fails the run. The body may also carry planOrigin, at run level or inside timing: local when the Assistant built the turn's step checklist locally, model when the planning model wrote it; any other value is ignored. timing.planOrigin wins over the run-level field. It is stored best-effort in its own sidecar, only when the run carries a valid timing object, and the timing report shows it beside the workflow as plan:local or plan:model.

Prompt prefix (STORY-113). The same timing object may also carry promptPrefixDigest (64-character lowercase sha256 hex of the turn's first-hop system prompt plus serialised tool definitions), stablePrefixDigest (the same hash over the prompt's stable leading part), toolSubset (the tool subset offered up front, for example base or base+files) and toolCount (how many tools the first hop carried, 0 to 1,000). Without a well-formed promptPrefixDigest the other three are ignored; any other invalid field is stored as empty. These are digests and labels only, never prompt content, and a later delivery of the same run replaces them.

Versioned account state

GET /crm/communications/connections and the connection-creating or lifecycle responses now carry an additive account object per connection: id, kind (shared or personal), owner_user_id, config_version, the caller's live allowed_actions, lifecycle (active|paused|disconnected), setup (draft without a sealed credential, configured with one), configured capabilities (inbound/outbound), separate checks.inbound and checks.outbound observations, and bounded sync progress. Each check reports state (unconfigured|unverified|checking|ready|failed), checked_at, a closed cause token and the credential_version it verified; the inbound check adds observed_count and window_days. A verified empty mailbox is inbound ready with observed_count: 0, and an outgoing failure changes only the outbound direction. Sync reports never|running|idle|failed with last_success_at, last_attempt_at, an item count and a closed cause token; no stored secret, token, or opaque cursor is ever returned. Scheduled mailbox sync runs only under a recorded person's authority, re-verified live on every run: sync.cause is authority_missing when no one has authorised the account, authority_lost when that person loses live membership or the required mailbox access (personal owners do not need integrations-management permission), and null otherwise. Connecting, resuming, reconnecting or replacing the credential as a signed-in member records that authority; an Atlas support actor never becomes one. GET /crm/communications/connections/:id answers with exactly that one connection's collection item — the same publicConnection + account shape plus support attribution when present — after live-authorizing only that connection, so a deep-linked account page does not wait for the whole organisation's pooled list; an unknown, cross-organisation or unauthorised id answers the sibling lifecycle routes' 404 not_found, and the collection read is unchanged.

GET /crm/integrations states each provider's availability and capability plus accounts - per-state counts of the caller-visible accounts on that channel - and can_create_shared, the current server decision for the existing shared-account creation path. The boolean is computed from live setup authority; it is false when authority denies creation and must not be inferred from a role, provider availability or account visibility. The collection remains empty when its reader is not authorized. An authority outage returns 503 authority_unavailable so clients can retry rather than treating it as permission denial. Counting runs one live authority check per connection, at most four at a time; GET /crm/integrations?accounts=omit skips those checks and leaves accounts out of every item while keeping the same admission, availability, setup and can_create_shared/can_create_personal answers — the web app uses it and counts GET /crm/communications/connections itself. The endpoint no longer returns one rolled-up provider status: several accounts on one provider each keep their own state, so a healthy account is never labelled by another account's disconnection. Configuration changes are version-guarded: PATCH /crm/communications/connections/:id with expected_version (required; missing answers 400 expected_version_required) applies one atomic label change and increments config_version, while a stale version answers 409 version_conflict with the live config_version and no partial mutation. Credential replacement bumps both the credential version and config_version. Checks and sync completions never change config_version, and a check that finishes after its credential was replaced is discarded by credential-version compare-and-swap instead of overwriting the newer result. Compatibility revision: while version-guard activation is pending, the legacy single-account mutation paths (pause, resume, disconnect, reconnect, test, credential replacement) still accept an omitted expected_version; a supplied value is enforced, and once activation ships those paths will answer 400 expected_version_required on omission.

Email setup (#2827). POST /crm/integrations/email/connect accepts optional account_kind: shared|personal and a non-empty directions array containing inbound and/or outbound. Active members with CRM write access can create their own personal email account. The owner is derived from live membership, never from request fields. New self-connected Email/Gmail accounts are private without enabling the organisation’s shared-account rollout. Historic dormant accounts remain dormant, and explicit organisation containment still stops personal access. Incoming and outgoing settings are encrypted independently in a v2 envelope; saved card hints expose only credential_envelope_version and configured-direction flags, never a password. Existing v1 credentials remain readable and an explicit replacement upgrades them to v2. Checks authenticate only configured directions; a successful empty incoming mailbox is ready with observed_count: 0.

Account lifecycle and named access (#2828). Legacy pause, resume and disconnect requests without expected_version resolve the current revision and use the same atomic command; concurrent edits may return 409 version_conflict. Settings sends the account's current expected_version with pause, resume and disconnect. These commands atomically persist lifecycle, configuration version and an attributed audit event. Resume requires a ready check for a current credential; failed capability checks remain failed. Disconnect revokes stored credentials, retains history and is final. GET /crm/communications/connections/:id/grants lists eligible active members and their independent can_read_review/can_send values for shared-account administrators; an unauthorized collection returns 200 {items:[]}. PUT on the same path accepts {expected_version,user_id,can_read_review,can_send}; stale edits return 409 version_conflict without partial grants or audit changes. These saved grants prepare the later named-access rollout and do not activate it. Email repair uses the provider credential replacement flow, never the ingestion-token rotation action.

Connect Gmail with Google sign-in

Gmail (#3062). GET /crm/integrations lists a gmail entry on the email channel with setup.mode: "oauth", accountKinds: ["shared", "personal"], both directions and no fields. It is omitted while the gmail-connector switch in Platform → Feature visibility is off for the caller (the default is developers only), and reads availability: "coming-soon" when the environment has no Google OAuth client. POST /crm/integrations/gmail/connect is refused: Gmail connects only through the Google pair below, under the same live setup authority as the Email create path.

POST /crm/communications/connections/google/start accepts {account_kind: "shared"|"personal", directions: ["inbound"|"outbound", …], label?, reconnect_connection_id?} and answers only {authorize_url} — Google's consent screen for openid email https://mail.google.com/, offline, with prompt=consent. Refusals: 403 gmail_connector_disabled while the switch is off, 503 google_not_configured when GOOGLE_MAIL_CLIENT_ID or GOOGLE_MAIL_CLIENT_SECRET is absent, 400 invalid_google_start, and 404 for a caller without setup authority or a reconnect_connection_id that is not one of the organisation's Gmail accounts. A reconnect passes Google's login_hint as the connected address. The state value is 32 random bytes; only its SHA-256 is stored (comm_oauth_states, migration 0093_comm_oauth_states.sql), bound to the organisation and the person, valid for ten minutes, and spent once.

GET /crm/integrations/google/callback is the address registered with Google (https://atlas.forth.ai/crm/integrations/google/callback, and the atlas.staging.forth.ai equivalent). It is a browser navigation, not an API: every outcome is a 302 to /settings/extensions/<id>?google=connected or /settings/extensions?google=<outcome> (the account page for a reconnect), with outcomes cancelled, state_invalid, already_connected (with account), wrong_account, email_unverified, check_failed (with a closed reason) and failed. The redirect never carries a token or a code. A return link that is reused, expired, cancelled, or opened by another person or organisation creates nothing. Before any account exists, the fresh access token runs the same live check as Email at the fixed hosts imap.gmail.com:993 and smtp.gmail.com:465; a failed check leaves nothing behind.

The vault row has provider: "gmail" and seals only {refresh_token, email}; the connection is channel: "email" with external_account_id set to the Google address, and its account object adds sign_in: "google". Access tokens are minted per use and never stored or returned. When Google answers invalid_grant, both directions become failed with cause google_access_revoked; sync and send stop with no automatic retry until a reconnect, which keeps the connection id and sync position and bumps the credential version. A Gmail account's send runs inside CRM (#2833): the Agent's credential lookup answers only {state: "crm_send", connection_id, credential_version, from_address}, the Agent hands the approved send to POST /internal/integrations/send, and CRM exchanges the refresh token for one access token and speaks AUTH XOAUTH2 to smtp.gmail.com:465. No token ever leaves CRM. A Google outage refuses the send as mailbox_unavailable with the approval intact; Google's invalid_grant refuses it as approval_required. A connected-but-refused Gmail account never falls back to the Atlas address. POST /crm/communications/connections/:id/disconnect on a Gmail account first revokes the access at Google (best effort; the disconnected activity records gmail:google_revoke_ok or gmail:google_revoke_failed), then revokes and destroys the sealed token.

Complete Forward source

GET /crm/inbox/items/:id/forward-source requires the Inbox feature, current source-account read_review and customer-owner visibility. Missing and unreadable sources return 404 item_not_found. The private no-store response preserves the attachment manifest's source and attachments, adding quote with from, to, cc, subject, date, body_text and body_kind (plain, html_text or empty). Structured Bcc, attachment bytes and storage keys are omitted.

Body text is complete from the same stored RFC822 generation as the attachments; it does not use the clipped detail reader or summarize quoted history. Complete plain alternatives are preferred; HTML is converted without executing scripts or fetching resources. Unsupported/incomplete MIME, charsets or conversion return 409 source_mime_invalid; overflow returns 409 source_limit; a changed source returns 409 source_changed. Canonical safety refusal returns 409 source_policy with no_draft. Limits: 1,000,000 UTF-8 body bytes; HTML input 4 MiB, 20,000 nodes and depth 128; existing raw/attachment limits remain. This read prepares source evidence only; draft confirmation and live dispatch fences remain separate.

Mailbox attachment manifest

GET /crm/inbox/items/:id/attachment-manifest requires the existing Inbox feature, current source-account read_review authority and customer-owner visibility. Missing and unauthorized items answer 404 item_not_found. The no-store response has source (item_id, connection_id, raw content_hash) and attachments (id, version, decoded-byte content_hash, part_id, filename, size_bytes). No bytes or storage locator is returned.

Version 1 identifies immutable source bytes; the opaque ID pins organization, source account, item, part and raw-message generation. Select an explicit subset and approve only its id/version/hash triples. CRM resolves the bytes under live source authority, verifies the hashes, and rechecks source generation and source/destination grants before SMTP DATA. Invalid, incomplete or oversized MIME refuses without sending. Limits are 32 MiB raw source, 20 attachments, 20 MiB each and 20 MiB selected bytes. Provider acceptance is not recipient delivery.

Open or download an email attachment

GET /crm/inbox/items/:id/attachments/:partId returns one attachment's bytes for an inbound email or an Atlas sent copy. :partId is a part_id from the item read's attachments (GET /crm/communications/:id). Authorization is that read's: the Inbox feature, current source-account read_review authority and customer-owner visibility. Another organization's item, an item the caller cannot see, and an unknown item answer 404 item_not_found; an unknown part answers 404 attachment_not_found. Stored emails up to 32 MiB are read; parts up to 20 MiB are listed and served, and a larger part is not listed.

The default is a download (Content-Disposition: attachment, application/octet-stream). ?inline=1 asks for display and is honoured only for a PDF, PNG, JPEG, GIF, WebP or plain-text file whose bytes match that type; HTML, SVG, XML and anything else stay a download. Every response carries X-Content-Type-Options: nosniff, a sandbox Content-Security-Policy and Cache-Control: private, no-store; the filename is sent as an ASCII fallback plus RFC 5987 filename*.

Attach files from your computer

POST /crm/inbox/uploads stores one file for an outgoing email. The request body is the raw file bytes; Content-Type is the file's type (anything that is not a bare type/subtype is stored as application/octet-stream) and X-Atlas-Filename is the percent-encoded file name. Only a signed-in member of the organization who may use the Inbox can upload: other callers get 403 forbidden, and 404 not_found while the Inbox is off. Success is 201 with a MailUpload {id, version, content_hash, filename, size_bytes, content_type}, where id is u1:<upload id>:<sha256>. No route ever returns an upload's bytes.

Refusals, before anything is stored: 400 upload_empty; 413 upload_too_large over 20 MiB, declared or actual; 400 upload_blocked_type for program files such as .exe, .js or .bat; 400 upload_invalid_name when the name is missing, empty after control characters and slashes are removed, over 200 characters or over 255 UTF-8 bytes. To send: PATCH /agent/assistant/task-drafts/:task_id accepts uploads, the complete MailUpload[] list for the draft; POST /agent/assistant/task-drafts/:task_id/send confirms that list in expected.uploads and refuses a difference with 409 draft_changed; POST /agent/assistant/compose/send accepts attachments: MailUpload[]. Uploads follow any forwarded source files, in order. At send, CRM resolves a u1: id only for the same organization and the person who uploaded it, verifies the hash against the stored bytes and rechecks before SMTP DATA. Another person's or organization's upload, a changed hash or missing bytes refuse before the mailbox credential is opened (attachment_mismatch, source_unavailable). Uploads and forwarded source files share the limits of 20 files and 20 MiB (source_limit).

Sent copies without a customer

After provider acceptance and command finalization, connected-mailbox sends retain the exact submitted MIME in Atlas Sent, including approved To/Cc and attachments. A send without a customer stays unlinked: it creates no customer contact or timeline entry and does not answer the original reply task. Bcc is absent from headers and participant metadata. Message-ID deduplication prevents duplicate copies. Copy storage is best effort: failure can leave the copy absent, but never resends or changes the accepted outcome. This is an Atlas copy, not provider-folder synchronization.

Approved mailbox recipient transport

Candidate implementation; deployment must be verified. POST /internal/integrations/send is an internal Service Binding interface, not a public mail-send API. Its existing approval binds one To address and up to 50 Cc and 50 Bcc addresses. CRM submits each distinct approved mailbox to SMTP; only To/Cc appear in the message headers and stored sent-copy participants. Domain case is ignored for deduplication; local-part case and plus tags remain distinct. Bare ASCII mailbox addresses are required.

A malformed mailbox returns refused with invalid_message before provider I/O. If any SMTP recipient is refused, no DATA is submitted and the attempt returns failed with a redacted message_refused cause and optional numeric reply code. Only immutable attachments from the authorized source manifest can be encoded. With no source resolver, nonempty manifests return refused/attachments_unavailable; invalid or inaccessible sources return bounded source errors. No attachment is silently omitted.

provider_accepted means SMTP acceptance, not recipient delivery. Existing approval, account authority, idempotency and linked-retry rules remain in force. An outcome_unknown is not proof of failure: inspect the recorded attempt; never blindly resend or reuse a key with changed content. Existing Workbench drafts remain the recovery surface. This transport change does not add an Inbox Reply all or Forward control.

Configuration per environment: GOOGLE_MAIL_CLIENT_ID is a CRM var (empty until the owner creates the OAuth client); GOOGLE_MAIL_CLIENT_SECRET is set with wrangler secret put GOOGLE_MAIL_CLIENT_SECRET --env <env>.

Named-grant backfill from live authority

#2825 — an organisation's existing shared accounts can be migrated to explicit named grants using Auth's own membership enumeration, never a role-name guess. GET /crm/integrations/grants/migration reports readiness, grant provenance counts and staleness; POST /crm/integrations/grants/migration/dry-run is read-only and counts the grants and differences an apply would make; POST /crm/integrations/grants/migration/apply is a bounded operator action for the organisation's own integration administrator (support sessions are refused by the Auth enumeration). Auth stamps every snapshot with a content revision — a digest over exactly the membership and permission state the enumeration reads — and the apply re-verifies it at finalization: a membership or permission change during the migration (409 revision_changed) or an explicit grant that would break old/new access parity (409 parity_mismatch) leaves the organisation not ready with nothing replaced. A successful apply atomically replaces migration-owned grants, preserves explicit admin edits by provenance, records the snapshot identity, bumps the shared accounts' config versions, and marks the organisation migration-ready. For a ready-but-unactivated organisation, a later revision change clears readiness on the next status read and a fresh apply safely refreshes migration-owned grants. An activated organisation refuses replay until a separately approved repair exists. Grants never widen CRM record access (customer permissions stay intersected at use time), never enable personal accounts, and a member removed after the backfill is denied by the live authority guards with ownership and history retained.

Account-model activation and containment (#2839)

The complete account model — per-member named grants plus member-owned personal mailboxes — goes live one organisation at a time through a coordinator-controlled release action, never a browser toggle. GET /crm/integrations/account-model reports the organisation's switch (activated, CAS revision, the recorded accepted guard-matrix matrix_revision, who activated or deactivated it and when) plus the live backfill readiness (ready, stale, unverifiable). POST /crm/integrations/account-model/activate requires {expected_revision} and the caller's live manage authority on the organisation's own integrations; the target organisation is always the verified session's. The gate revalidates the recorded grant-backfill proof against the live Auth membership/permission revision in the same request: a confirmed change answers 409 stale_membership and clears readiness until a fresh backfill apply, an unreadable revision answers 503 unverifiable (the model never opens on an unknown), a missing or incomplete backfill answers 409 not_ready, and a concurrent edit answers 409 version_conflict. A refused activation writes nothing. A successful activation records the accepted guard-matrix revision, the actor and the time on the row — the durable release record.

POST /crm/integrations/account-model/deactivate is containment, not a rollback: with {expected_revision} it turns the organisation's switch off and stops new-model account creation, personal-mailbox sync and personal send everywhere guards re-read the flag, while read restrictions and all history are retained and nothing falls back to legacy organisation-wide reads — a dormant personal account returns to the redacted audit-only view, never to content access. Deactivation never touches stored credentials, grants, metadata or message history, and re-activation after a refresh follows the same proof gate. On an activated organisation, member personal creation is admitted only at the guided connect route with the owner derived server-side from the caller's live authority (the body's owner field is ignored); every subsequent read, send, sync and agent sender projection still consumes the live authority decision, so the activation snapshot is never a lasting permission grant. Removing the obsolete Agent credential secret is a separate verified configuration step performed only after no remaining use — never part of activation, and never a secret rotation.

Email-address-only resolve and rep self-connect (#3596)

POST /crm/integrations/email/resolve turns a rep's bare email address into a connect plan without ever asking her for a host, port or protocol: {email} resolves through public MX records, then RFC 6186 SRV, then an embedded autoconfig table, then a support handoff, returning {provider_key, display_name_en, display_name_zh, imap:{host,port,tls}, smtp:{host,port,tls}, auth_hint, guide_key, confidence}. A malformed address answers 400 invalid_email; nothing resolving answers 200 with confidence:"none" and a placeholder host that is never dialable (RFC 2606's .invalid), signalling the UI's support handoff rather than a bare settings form. Any authenticated organisation member may call this — it leaks nothing beyond what the domain's own public DNS already publishes, and it is deliberately outside the account-authority band above (no manage/inspect_setup check). Before returning an SRV-sourced host (the one path where the value comes from a DNS answer rather than a code-declared table), the route rejects a private, loopback, link-local or otherwise reserved IP literal and silently downgrades to the confidence:"none" shape instead — never surfaced as a distinguishable error, so a hostile domain cannot use the response to probe the guard. A simple per-session/organisation in-memory rate limit (20 calls/minute per isolate) answers 429 rate_limited beyond its window — a soft cap on one noisy session's outbound DNS volume, not a distributed-abuse guarantee, which Cloudflare's own platform-level rate limiting covers instead.

A narrower create_own authority lets active members with live crm:write create their own new personal Email/Gmail mailbox without integrations permission or organisation-wide activation. Explicit organisation containment blocks this creation. POST /crm/integrations/email/connect accepts account_kind:"personal" for a fresh connection; Gmail uses the Google start/callback pair. The owner comes from live membership, never owner_user_id in the request. The mailbox domain may differ from the member’s sign-in or organisation domain. A platform administrator whom Auth verifies as an active member of the platform home organisation follows these same personal Email rules. Cross-tenant support receives no self-connect authority. Shared creation still requires manage. Repair requires the named account’s live manage authority, which an eligible personal owner can hold. No historical dormant account is activated by this change.

Eligible saved mailboxes retry on the scheduled sweep, normally every five minutes; owners can also use Sync now, backed by POST /crm/communications/connections/:id/sync. Scheduled and manual imports allow up to 120 seconds per IMAP session. The immediate post-connect background kickoff uses a shorter 20-second session and one-message batch; later runs continue from the saved cursor. These are provider-session limits, not a promise that a whole mailbox finishes in two minutes. Paused, disconnected, unverified or unauthorised accounts require their displayed issue to be resolved first. An account rejected before saving is not scheduled for retries.

POST /crm/communications/connections/:id/history accepts {expected_version,history_days}, with history_days:"all" or an integer from 1 to 90. It changes only an active IMAP Email account’s saved history policy, using its existing sealed credentials; Gmail is unsupported. The caller must be a live member with both manage and read_review. Private owners follow the same rules, including verified platform-home members; setup-only support is refused. Success returns {connection,account,history_days,changed}. Missing version or invalid history is 400, a stale version is 409, and an unavailable or inaccessible account is 404. No provider password is returned or required. The authorised single-account read also returns the saved credential_hint.history_days, so reloading an account preserves its displayed history choice. Only masked configuration hints are returned.

A changed policy advances configuration and credential generations and restarts the Inbox cursor while keeping saved messages, review state and matching provider verification. Old in-flight scans cannot commit the old cursor. Repeating the same policy returns changed:false without another rewind. credential_hint.history_days is non-secret setup metadata: a numeric string or all. All dates in INBOX become eligible; other folders and the default 30-day policy on other accounts are unchanged. Imports continue in bounded batches, with existing deduplication and privacy checks. An open, visible Inbox refreshes as batches arrive; a successful batch is not full-mailbox completion. The connection check’s seven-day count is a diagnostic lookback, not the saved import scope.

Recovery: before reverting CRM to a version that cannot parse all, each affected account must first be restored to a numeric window through this version-guarded endpoint while the compatible CRM is still live. Use an authorised member for each account; platform support is not a private mailbox owner. Otherwise keep an all-compatible reader and repair forward. This recovery does not delete already imported mail.

Every active member can discover the Email plugin through GET /crm/integrations, including before the organisation account model is activated. A member without inspect_setup receives only static email IMAP/SMTP and enabled gmail setup profiles: no accounts counts, credentials, account identifiers or account-storage reads. accounts=omit keeps the same metadata. can_create_shared and can_create_personal still report existing live creation authority; discovery grants neither. When setup is unavailable, the plugin asks the member to contact an administrator. Discovery itself grants no mailbox authority; newly self-connected personal Email/Gmail follows the owner-only policy below. Shared mailbox management, private content checks and Google consent remain enforced. Unavailable live authority still returns 503 authority_unavailable.

POST /governance/internal/team-evaluations/method-link/read is an internal, staging/development-only Service Binding endpoint. It accepts only a time-, route- and body-signed EVAL_SECRET request with evaluationId, orgId, hireId, manifestDigest, and inventoryDigest. It returns an atlas.method-evaluation-link.v1 link after checking stored row bindings, manifest validation and inventory recompilation, exact supported fictional family seeds, the current organisation policy digest and current human admin authority. Malformed, stale, cross-organisation, legacy, unknown or unsigned reads return an empty 403; production is unavailable (404). It creates no link, trial, queue item, approval, or publication.

The frozen link carries pinned control/proposed references, an account or organisation destination and full run/profile identity, with publicationEligible=false. It accepts no uploaded instructions: instructionDigest hashes the exact learned-slot text (empty for base), while resolvedSkillDigest hashes the complete resolved skill text. The run identity's configuration digest is separate. This reader validates the Agent policy digest's shape; the later Agent callback must resolve existing records and check current Agent policy, revisions and both text digests. The additive method result envelope retains the trial callback fields plus full immutable attemptRecords. Shared generation forwards its optional observer on success and failure, including fallback attempts; empty attempts do not prove billing. Existing output JSON remains unchanged.

Internal signed method trial

POST /agent/internal/team-evaluations/trial accepts a method-only signed body with method: {link, executionCaseId, trialOrdinal}, a reviewed fixture and its worldDigest. Here link contains only the five reader selectors above; Agent retrieves the stored link itself. It checks current hire, candidate, admin and assignment authority, retained evidence, policy, exact learned-slot/full-skill/configuration digests, supported profile, normalized family sources, hashed per-variant world and current CRM snapshot before model work. Legacy trial bodies remain supported separately.

The bounded Service Binding response is {status: "completed", result, answer} or {status: "failed", result, error}. The frozen result remains content-free, including actual review/draft attempt identities on fallback and failure. Governance correlates the descriptor, variant, ordinal and billed run, then immediately grades the actual answer with the unchanged fictional-world oracle. Answers are limited to 64,000 UTF-8 bytes and never enter method traces or generic runner answer storage. Durable method evidence contains identities, assertion codes and an output digest; publicationEligible stays false. This adapter creates no admission, queue or publication path and proves no live-model quality.

The response contract is derived from the repository-owned oracle; signed fixture overrides are refused before model work. Every attempt, including provider fallback, rechecks the stored link and organisation policy, current authority, frozen learned records, retained evidence and exact skill digests. Replacing an active skill head does not replace the pinned control record. Invalidation, policy drift or changed pinned content stops further provider work and retains the attempts already observed.

The existing diagnostic queue consumes stored method descriptors with their three fixed ordinals. Its report JSON retains methodTrial (descriptor, ordinal, billed run, actual per-purpose attempt records and output digest) and the owning Auth/CRM cleanup manifests for both fictional worlds. Repeated delivery never starts an extra callback; interrupted completion recovers the durable settlement, and an orphan without a settlement remains failed with unknown billing. Unsupported method adapters or report versions are refused. Report access retains the caller's original credential and requires current administrator membership, the immutable same-organisation link and live Agent hire inspection; callback IDs are never treated as ordinary TeamStore runs. Bearer-only evaluation report requests remain unsupported and fail closed. Method admission and launching are implemented, but refuse without an actually reviewed compatible case binding. The shipped reviewed method-binding registry is empty; fictional test bindings do not activate it. This evidence does not establish live-model quality or publication eligibility.

Cleanup succeeds only after remaining-state readback finds no Tasks or active EKB sources in either exact world and both owners return zero remaining rows. Report verification validates every frozen descriptor field and derives the billed-run identity from the campaign, execution case and ordinal; missing terminal evidence, fabricated correlation and malformed or unknown stored link formats fail closed. Supported legacy links remain separately readable.

A content seal is not proof of provenance. Reading, summarising, comparing or repeatedly creating a stored method report also requires trusted context from the owning immutable link and pinned admission inventory after current caller authorization. Caller-supplied lookalikes are ignored. Missing or inconsistent method context returns 409 integrity_failure; malformed report structure or a damaged content seal retains 500 integrity_failure. Offline method verification without trusted context is refused; legacy reports retain their existing verification.

Verification rederives the full method report projection from the owning terminal campaign and trial rows, including ordinal coverage, per-case rows, attempts, cleanup and aggregates. Abandoned diagnostics retain unfinished rows as unknown/incomplete, without invented attempts. Method ownership is also recognized independently from admission: a missing owning link returns 409 integrity_failure after current membership/role/organisation authorization, since no trusted hire selector remains. With a valid link, revoked hire access still returns 404.

Linked evidence in profile-gap Task proposals

The existing tasks.profile_gap_task.create proposal adds data.proposal.evidence.linked_sources: at most 50 explicitly linked files, ordered by source_id,link_id. CRM evidence stays in its existing fields. Each source has the following shape:

{"link_id":"link_1","source_id":"libf_1","state":"current","citations":[{"schemaVersion":1,"reference":{"schemaVersion":1,"kind":"knowledge_source","owner":"ekb","id":"libf_1"},"asset":{"id":"libf_1","title":"Customer notes"},"locator":{"type":"passage","index":0},"quote":"Contact channel reported by the source."}]}

A readable source is current; each citation contains its real source ID/title, a zero-based passage index, and a verbatim prefix capped at 200 Unicode code points. At most three passages per file are returned in index order. [] means CRM has no associations. Other source states have empty citations: source_unavailable (missing or foreign source, indistinguishable 404), inaccessible (no existing Knowledge read authority or 401/403), no_passage (valid file without passages), and owner_unavailable (missing binding, timeout, 503, malformed or unexpected response). CRM enrichment unknown retains the association; an independently authorised EKB read may still provide citations.

The proposal still requires tasks:write; delegated citations additionally require that same token's existing ekb:read. Missing Knowledge access does not change Task eligibility. Source text never changes CRM facts or Task metadata. Review both evidence fields before explicitly confirming. Changed linked IDs, states or citation content return 409 proposal_stale and create no Task: propose again and review the new evidence. An unchanged confirm retains its existing idempotency; reject creates no Task.

Propose with POST /assistant/v1/actions/tasks.profile_gap_task.create; confirm with POST /assistant/v1/proposals/:id/confirm, using the existing Idempotency-Key header.

Send the same requests from any HTTP client. Base URL: https://atlas.staging.forth.ai.

HeaderValue
AuthorizationBearer <token> on every request.
Content-Typeapplication/json on every POST.
idempotency-keyRequired on propose, confirm, and reject. Reuse the same key to retry safely.
curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/crm.customers.search \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"q":"Acme Japan VIP","limit":5}'

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/crm.customer_profile.create \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "idempotency-key: acme-1" \
  -d '{"name":"Acme","country":"SG"}'

curl -sS -X POST https://atlas.staging.forth.ai/assistant/v1/proposals/<id>/confirm \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "idempotency-key: acme-1-confirm"

Request bodies use the field names from the MCP tool inputs: customer_id, q, query, limit, type, source_id, name, contact, email, country, industry, customer_type, profile_gap_code, title, due_at, assignee_email, suggestion_id, page_no, ordinal, operation, audience, valid_until, attestation_id, purpose, sections, cursor, case_id, source_kind, expected_revision, remaining_balance, state, due_on, note, year.

Response envelope (atlas.action.v1)

{
  "schema_version": "atlas.action.v1",
  "action": "crm.customer_profile.create",
  "status": "awaiting_confirmation",
  "request_id": "…",
  "data": { "proposal": { "id": "…", "state": "pending", "expires_at": "…", "...": "…" } },
  "confirmation": { "id": "…", "summary": "…", "expires_at": "…" },
  "meta": { "catalog_version": "…", "principal_kind": "…", "idempotency_key": "acme-1", "repeated": false }
}

status is one of completed, awaiting_confirmation (HTTP 202), forbidden, rejected, conflict, retryable_failure, terminal_failure. Failures carry error.code and error.message instead of data.

Unsaved method suggestion

Send the JSON below to POST /assistant/v1/actions/agent.team.change with an Idempotency-Key. Confirm the returned proposal using POST /assistant/v1/proposals/<proposal-id>/confirm and a confirmation key. Proposing does not call a model; confirmation creates one charged draft attempt.

{"operation":"teaching.suggest","hireId":"amhire_example","revision":1,"teachingSuggestion":{"runId":"amrun_example","excerpt":"The follow-up has no clear question.","expected":"Ask for the next decision date."}}

destination defaults to account; authorised teaching proposals may select organisation. Evaluation and publication retain their existing administrator restriction. excerpt allows an empty string and at most 2,000 characters, treated as untrusted data; expected is nonempty and at most 2,000 characters. Customer-fact corrections are refused; use the existing teaching.create / customer_fact flow.

Completed data contains top-level runId and hireId, plus suggestion: proposedInstructions (up to 3,000 characters), rationale (up to 800), inputDigest, runId, hireId, and source.runId. The digest binds the selected input and current authorised account/organisation methods. Review this unsaved result, then explicitly submit a separate teaching.create proposal if wanted; generating never saves, publishes or activates a candidate.

Replay rechecks current permissions, source access and method evidence. Identical confirmation recovers a retained result; changed methods return proposal_stale. An interrupted attempt with no recoverable receipt returns result_unavailable, with no further gateway invocation for that operation. Invalid generation returns suggestion_invalid or suggestion_unavailable. Keep the original input; a fresh explicit proposal is a separate charged attempt. Source expiry clears generated content. Usage is attributed to teaching_suggestion, with mandatory credit reservation and a 1,024-token output cap.

Paired teaching diagnostic

Use the same proposed/confirmed agent.team.change flow with {"operation":"teaching.compare","hireId":"amhire_example","candidateId":"amlearn_example"}. Only a current human administrator can start it. In staging and development the server resolves the current candidate, source evidence, execution profile, reviewed published pack, case bindings, target reservation and credit bounds; callers cannot submit reviews or case identities, and the honest current response is method_experiment_unavailable before any world or model call. On production, the comparison is a replay of the lesson's recorded anchor run: the recorded inputs are re-read (drift refuses), the review is regenerated with and without the lesson through the effect-free generation core, and a frozen judge grades the control leg against the recorded output and the proposed leg against the human-authored correction. The replay sends nothing, writes nothing except its own evaluation record, is metered against the organisation's credits, and is audited; only a passing comparison moves the candidate to ready, and a failed or inconclusive one blocks teaching.publish exactly as before. Repeating the same operation key returns the same comparison.

Admission stores the reviewed manifest, complete descriptor-backed case definitions, link and all trial rows atomically before scheduling. If delivery is interrupted, confirmation returns a retryable failure, retains the existing reservation and does not record a completed effect. Retry the same proposal confirmation (with the same or a new confirmation key) to resume its original pending rows; the existing queue and drain claim each trial once. A completed schedule replays without new scheduling or preflight, and a terminal campaign remains readable after normal reservation reconciliation. Current human administrator and hire/candidate visibility checks still apply on every retry; later registry or budget changes do not replace the frozen campaign. Trial credit failures remain failed evidence, with no replacement trial. Report creation uses POST /governance/eval/campaigns/:id/report; read the sealed result at GET /governance/eval/reports/:reportId.

After proposal expiry, confirmation can recover only the same already-admitted campaign. An expired proposal with no admission is still refused and cannot launch a new campaign; recovery retains current authority and original-request checks.

Hire permission level (agent.team.change)

Every hire carries one configuration.permissionLevel of plan_only, co_pilot or auto_pilot on hire.create and hire.configure. Omitting the field stores nothing: the key is then absent from the configuration a read returns, and an absent key means co_pilot, today's behaviour — the hire prepares work and a person decides. Apply that default client-side rather than expecting the string on the wire. The level rides on the hire revision, so agent.team.inspect views team and detail return it on hire.configuration.permissionLevel and each change is a new revision with its actor and time.

A customer-scoped hire also carries one configuration.role on hire.create and hire.configure: presale_assistant for the Pre-sale Assistant, customer_success_manager for the Customer Success Manager, or the field omitted for the Account Manager. Omitting it stores nothing and reads back absent, which means account_manager — today's behaviour. Only a customer-scoped role may be named: sales_assistant is the portfolio scope's own role and is refused, as is any catalog role whose behaviour has not shipped. A Pre-sale Assistant run produces a cited pre-sale brief instead of an account review; it never creates a quotation by itself — crm.engagement.quotation-draft.prepare is a separate action a person confirms. A Customer Success Manager run produces a cited post-order health brief (open receivables, order delivery and shipment progress, recorded after-sales feedback) and reaches at most two decisions: a follow-up task for a receivable with no confirmed settlement, and the previous year's cooperation report prepared through crm.engagement.drafts.change (purpose: annual_cooperation_report over business_ref_kind: annual_evidence) when none is approved yet. Approving that decision performs the draft change under the approving person's own authority and leaves a pending draft for review; the hire never performs it and nothing this role produces is sent.

plan_only finishes scheduled and on-demand runs with the brief, limitations, next actions and drafts readable on the run and creates no decision items in Today. co_pilot creates the same pending items as before, and nothing executes without a human confirmation.

Auto-pilot execution (agent.team.inspect)

A hire on auto_pilot confirms its own prepared work through the same proposal ledger a person confirms it through, under the hire's own delegated grant. Nothing new executes: the same task creation and the same saved draft, decided by the hire. Each confirmation leaves one proposal, one effect receipt whose actor is the hire (carrying the hire id, the run id and the inverse effect for a later undo), and the work approved with decidedBy = agent:<hireId>. The idempotency key is auto:<runId>:<workId>, so a retried run replays the recorded outcome instead of executing twice.

One run confirms at most ten actions. Everything past the cap stays pending for a person in Today, and the run carries autoPilot with executed, leftPending, cap and any named failures. Pausing the hire stops it at once, including a run already in flight: that run ends cancelled with assignment_changed and nothing further lands. If the hire's assignment authority is revoked — or its grantor is no longer an active member holding tasks:write — the action fails with authority_revoked recorded on the run, executes nothing, and is never retried under another principal.

agent.team.inspect view decisions answers an additive doneByAiTeam array beside items: the approved work whose decidedBy starts with agent:, newest first. items is unchanged.

A change carrying auto_pilot is refused with 403 admin_required, writing no revision, unless the caller is an organisation administrator in a live organisation session; a rep supervising their own Sales Assistant, a delegated token and a scheduled agent principal are all refused. The proposal's evidence carries permissionLevel, the role's allowedActionClasses from the role catalog, and excludedActionClasses (external_send, binding_quotation, never allowed for any role). Read them at GET /assistant/v1/proposals/:id before confirming; if the allowed set changes after the proposal, confirmation returns proposal_stale.

RFQ handoff (agent.team.change)

{"operation":"run.handoff","hireId":"amhire_presale_example","revision":<hire revision>} hands an active Pre-sale Assistant's requirements matrix to the active Account Manager hired on the same customer. Propose and confirm it like every other agent.team.change; the supervisor of both hires or an organisation administrator may confirm it. Confirming creates two runs with trigger handoff — one on the Pre-sale hire and one on the Account Manager hire — and starts one Workflow for the pair. The Pre-sale run records its cited brief exactly as its other runs do and publishes no work of its own. The Account Manager run records the Pre-sale run's id and source digest as handoff evidence before its one governed model call (purpose rfq_handoff), then publishes one clarification task Clarify: <requirement> for each of the first three gaps in matrix order and one follow-up email draft, each citing the requirement it addresses. Further gaps are named in the run's limitations as Not asked: <requirementId>. Everything stays pending for approve, edit or dismiss; nothing is sent.

Refusals: 409 handoff_unavailable when the hire is not an active Pre-sale Assistant, the customer has no active Account Manager, the caller cannot start runs on both hires, either hire's delegated authority does not resolve, or a handoff of this hire is still queued or running. A Pre-sale run that fails or finds no requirement ends the Account Manager run failed with handoff_source_failed; an Account Manager step whose authority is refused ends it failed with its reason. Neither publishes anything. Confirming again with the same idempotency key returns the same run pair and starts nothing new.

A Pre-sale hire's detail view (agent_team_inspect view:"detail") carries handoffAvailable and, when it is false, handoffUnavailableReason: one of no_account_manager, plan_only_account_manager, handoff_in_flight, no_access, unavailable. A handoff counts as in flight while either run of its pair is queued or running and its Workflow has not finished; when the Workflow ends after a step exhausted its retries, the unfinished runs end failed (handoff_abandoned, or handoff_source_failed for an Account Manager run whose Pre-sale run did not complete) and a new handoff can start. Two confirmations racing under different keys create one run pair; the other is refused 409 handoff_unavailable. When the Workflow cannot be started the confirmation answers 503 handoff_unavailable, ends the pair handoff_abandoned and can simply be retried; a detail read that cannot decide reads unavailable. The Account Manager run's output and work derived from a handoff are shown, and may be approved, only to a member who can also see the Pre-sale hire now and only while the Pre-sale run is still the retained, completed run it recorded; anyone else reads them as evidence_unavailable, the same as withdrawn evidence. Gaps beyond the three asked are each named in the limitations; when they do not all fit, the rest are named together in one final Not asked: <id>, <id> entry.

Undo an auto-pilot action (agent.team.change)

{"operation":"work.undo","hireId":"amhire_example","workId":"amwork_example","revision":<work revision>} applies the inverse the receipt recorded when the hire executed the action: the task the hire created is cancelled through Tasks, and a draft it saved is discarded. It reverses nothing else, and it never writes a newer state over someone else's. The hire need not still be active. Propose and confirm it exactly like every other agent.team.change; the proposal's evidence pins the work revision, the hire revision, the effect and a digest of the recorded inverse, so anything that moves in between returns proposal_stale.

It happens once. A repeated confirmation of the same proposal returns the first outcome; a second, separate Undo of the same action is refused already_undone and reverses nothing twice. An action whose receipt carries no recorded inverse is refused no_inverse_effect; an action a person confirmed is refused not_auto_executed; a created task that is no longer open — closed, cancelled or gone — is refused world_changed with the task untouched and the action still recorded as executed. Every refusal changes nothing.

Authority is the authority deciding the action would have needed, live: crm:write, visibility of that hire, and tasks:write for a task action (403 forbidden without it). A hire's own scheduled-agent grant is refused work.undo outright, and so is any service identity: the undo is always recorded against the person who confirmed it, never against the hire. The work reads back with an additive undo of { "at": <epoch ms>, "by": "<person>" } on the detail view and on doneByAiTeam; work never undone carries no such field. On the detail view that work's live status also reads undone while the row keeps its effectId, its recorded decision and the undo object; the doneByAiTeam decision history keeps the action's recorded decision.

Withdraw learned guidance (skill.withdraw)

{"operation":"skill.withdraw","hireId":"amhire_example","revision":<hire revision>,"scope":"account","fromVersionId":"amskill_example","headRevision":<head revision>} moves the named account or organisation skill head back to the base version, marks the withdrawn version invalidated and records one skill_withdrawn event. It names no target. Like skill.rollback it is an administrator operation (403 admin_required otherwise) and needs the head revision the administrator read: a head changed by a concurrent publish, rollback or withdrawal is refused 409 skill_head_changed and nothing changes. The base version cannot be withdrawn (409 invalid_withdraw_target).

The next run, draft and chat turn use the base skills only, and the run identity records the base version. Recorded runs, events and the version row stay readable exactly as recorded. A withdrawn version is never a rollback target again; recovery is a replacement through the unchanged teaching.create → teaching.evaluate → teaching.publish chain. The same payload is accepted by POST /assistant/v1/actions/agent.team.change and agent_team_change_propose; confirm the proposal like every other AI team change.

Account Manager run usage (agent.team.inspect)

The existing Governance GET /usage collection returns a normalized purpose on each visible event. Legacy null or unsupported values appear as unknown, without rewriting stored events or changing token totals, prices, credits or idempotency. Organization and actor visibility rules are unchanged.

POST /assistant/v1/actions/agent.team.inspect with {"view":"detail","runId":"<run-id>"} returns the currently authorised run's detail using the existing crm:read scope. Choose exactly one selector: runId for one run or hireId for a hire's detail; sending both returns 400 invalid_input. Another organisation's run or lost current access returns the existing denial without usage or source content.

data.usage.byPurpose groups existing usage as account_review, follow_up_draft, teaching_suggestion, method_optimization or legacy unknown. Each entry includes attempts, coverage (provider-token, rated-money, credit and pending attempt counts), token totals, providerCostNanos and costCurrency, and separate customerCredits. Cached and reasoning tokens remain subsets and are never added to totalTokens.

data.usage.pendingAttempts counts unsettled attempts. data.usage.incomplete and each entry's incomplete flag missing or incompatible measurements; unavailable totals remain null. An empty projection, including a run with only pending attempts, has run-level incomplete: true. Pending attempts make totals unavailable. Mixed or missing currencies make provider money unavailable while independently measured tokens and credits remain separate. If Governance cannot be read, available: false and incomplete: true indicate unknown coverage; a pending count is not invented.

The specialist loop every AI team role runs on, and its comparison (STORY-DRAFT-AT-02, STORY-DRAFT-AT-03)

While the team-specialist-loop feature flag is open for an organisation, a scheduled, Run now or inbound-email run of ANY catalogue role — Account Manager, Sales Assistant, Pre-sale Assistant, Customer Success Manager, Business Analyst — executes on the shared specialist loop: at most six governed model steps, each with its own credit hold against the hire's budget and recorded with purpose specialist_turn in data.usage.byPurpose. Each role reads through its own bounded tool list: the customer's authorised sources and assigned knowledge for customer-scope roles (plus the Pre-sale requirements matrix or the Customer Success engagement context), the rep's portfolio for the Sales Assistant, and the organisation's analytics snapshot for the Business Analyst — an organisation role reaches no customer tool and a portfolio role no organisation tool. Every run ends in its role's existing stored shape (brief, next actions, drafts), pending approval. A preview, an evaluation trial and an RFQ handoff keep their single-call and deterministic paths for every role, and a closed or unreadable flag keeps every run on them. The run detail's identity.tools lists the steps the loop took, in order, as [{"name","at"}] (names only); it is absent on a run another path executed. The agent.team.inspect and agent.team.change payloads are otherwise unchanged.

POST /agent/team/specialist-comparison with an optional {"hireIds":["<hire id>"]} or {"role":"<role id>"} is a staging and development operator tool (production answers 404) for an organisation administrator with crm:write; anyone else is refused admin_required. One request compares ONE hire of ANY catalogue role, because it runs both arms and can take several minutes: the named hire, the most recently updated active Account Manager hire when both fields are omitted, or the most recently updated active hire of that role when only it is given (an unknown role, or both fields together, is refused 400 invalid_input; more than one id likewise). It captures the hire's evidence through read-only transports and runs both that role's own pipeline (the generation calls for the Account Manager and Sales Assistant; the deterministic composers for the Pre-sale Assistant, Customer Success Manager and Business Analyst) and the loop on it; nothing is recorded on a run and nothing is published, but both arms spend governed credits. The response carries cases (per hire with its role: pipeline and loop with status, counts of brief items, next actions and drafts, references, citationsValid checked against that role's own evidence, durationMs and credits per arm — credits summed from the governed usage ledger, 0 for the deterministic composers, null when the ledger could not answer in time — and the loop's steps and stop), summary per arm (parseSuccess, citationValidity), approvedShare read from the organisation's real supervisor decisions of the last 30 days for that role's work, split by the engine that produced each run (auto-pilot approvals excluded), verdict (no_worse, worse or inconclusive for each measure, and ship) and gaps.

Both arms use captured evidence, skill instructions and one case time. Before each model or tool read and before returning the report, the comparison rechecks the live administrator, hire assignment and source authority. Changed evidence, role settings or resolved skills fail the case with comparison_evidence_changed; revoked access and deleted customers retain their own refusal codes. Failed generation arms still report settled ledger credits; unsettled, unavailable or unknown cost, including an empty or missing usage projection, is null, never an assumed zero. An explicitly measured zero remains 0; incomplete token or currency metrics do not erase independently measured credits. A source failure before either arm starts costs 0. A report with either arm failed has citation verdict inconclusive and ship: false, even when the numerical citation rates tie. The replay records no supervisor decisions.

Organisation role enablement (role.configure)

An organisation decides which AI team roles it runs. POST /assistant/v1/actions/agent.team.inspect with {"view":"roles"} returns data.items: one entry per catalog role with roleId, enabled, revision, updatedBy and updatedAt. A role never switched reads as enabled at revision 0. Administrators only; anyone else is refused admin_required, and the read is always scoped to the caller's own organisation.

{"operation":"role.configure","roleAvailability":{"roleId":"sales_assistant","enabled":false,"revision":0}}

Send it to POST /assistant/v1/actions/agent.team.change with an Idempotency-Key and confirm the proposal like every other AI team change. role.configure takes roleAvailability only and no hire subject. revision must be the stored revision the administrator saw; a proposal or confirm carrying any other revision is refused stale_revision and changes nothing, so two administrators cannot overwrite each other. The completed data.roleSetting is the new row at the next revision.

While a role is switched off, hire.create, hire.configure and role.enable for it are refused 409 role_disabled at propose and again at confirm — no hire, hire revision, run, proposal or receipt is written. Existing hires of the role keep their status, revision and configuration but start no scheduled run; the sweep records one schedule_stopped event carrying role_disabled, and every team and detail read marks such a hire with roleDisabled: true. Switching the role back on resumes the configured schedule on the next sweep with no reconfiguration.

AI team run health (agent.team.inspect)

POST /assistant/v1/actions/agent.team.inspect with {"view":"team"} returns an additive data.runHealth beside the unchanged data.items: how the caller's visible hires have been running over the last seven days. It carries since (window start, epoch ms), runs (every counted run created in the window, finished or not), terminalRuns (the ones that reached an outcome — completed, unchanged or failed), degraded (completed runs carrying reason: "output_degraded" — the run kept the evidence it gathered but produced no written review), failed, the two shares degradedRate and failedRate (null when nothing finished in the window), hiresCounted and hiresTruncated, and byHire: one {hireId, degraded, failed} entry per hire that contributed a degraded or failed run, worst first. A healthy organisation answers an empty byHire. degradedRate and failedRate are shares of terminalRuns, the runs that finished, not of runs. Trial and evaluation runs are not counted, and neither are runs governance has purged. hiresCounted says how many hires were counted and hiresTruncated is true when the organisation may hold more. The same block, counted over the metrics period, also rides view metrics as runHealth.

A degraded run still completes, still keeps its evidence and still advances lastRunAt, so nothing that counts failures counts it; this rollup is the count. It is scoped to the caller's own organisation and to exactly the hires that caller may already see — a supervisor's rollup covers their own hires, an administrator's the organisation's — so it never discloses a hire the team list would not. It is a rollup, not an alert: nothing is dispatched or paged from it.

Customer AI team view (agent.team.inspect)

POST /assistant/v1/actions/agent.team.inspect with {"view":"customer","customerId":"<customer-id>"} returns data.items: the caller's visible non-retired hires assigned to that one customer, in the same shape the team view returns, each with role, status, configuration.permissionLevel, configuration.supervisorEmail, lastRunAt and configuration.schedule. A hire whose role the organisation switched off carries roleDisabled: true here too.

One account carries a whole AI team, but at most one non-retired hire of each role. {"operation":"hire.create"} for a role a non-retired hire already holds on that customer, in that organisation, is refused 409 role_already_assigned; the envelope error carries existing_hire_id and existing_role naming the hire that holds the job, and no hire, hire revision, role row or uniqueness row is written. Two creates of the same role, customer and organisation at once leave exactly one hire and answer the other the same way. A different role on the same account is an independent hire, and retiring the holder frees the role again. The same refusal, code and fields reach MCP and the CLI unchanged.

Visibility is the team list's, narrowed — never widened: an administrator reads their organisation's hires on that account, a supervisor reads their own. A caller who may see no hire there, a customer of another organisation, and an account with no hires all answer 200 {"items":[]}, so the read never confirms that a customer or a hire exists. A missing or empty customerId is refused 400 invalid_input; a session outside the caller's organisation is refused authority_revoked as on every other view. The view reads only stored hires — customer names come from CRM, never from this response.

Business Analyst hire (organisation scope)

The Business Analyst is hired once for the whole organisation, not for a customer or a rep. Its hire configuration carries "assignmentScope":{"kind":"organisation","roleId":"business_analyst"} and the reserved customerId organisation:business_analyst; the two must agree, knowledge must be empty, and permissionLevel may be plan_only or co_pilot only — auto_pilot is refused for this role in v1.

{"operation":"hire.create","configuration":{"customerId":"organisation:business_analyst","assignmentScope":{"kind":"organisation","roleId":"business_analyst"},"supervisorEmail":"[email protected]","name":"Business analyst","goals":["Explain what changed each week"],"knowledge":[],"skills":["account_review"],"instructions":"","locale":"en","schedule":{"timezone":"Asia/Shanghai","time":"07:30","weekdays":[1]},"budget":{"monthlyCredits":100,"perRunCredits":10},"permissionLevel":"co_pilot"}}

Administrators only (admin_required). A second hire while one is not retired is refused role_already_hired at propose and again at confirm, so two administrators hiring at the same moment end with exactly one analyst and nothing else written. Each scheduled run makes one signed organisation-scoped read of the analytics aggregates — /analytics/kpis, /analytics/customer-activity, /analytics/pipeline, /analytics/top-opportunities and /analytics/needs-attention — scoped by CRM to the hiring organisation and to the supervising principal's own visibility, and analyses the weekly or monthly window the organisation's retrospective cadence says has closed. Every figure in the analysis carries the endpoint it came from. The four report keys pipeline-analysis, customer-analysis, sales-performance and sales-forecast are named in the run's limitations and never read: one that is not released for the organisation is named as not released, and a released one is named as publishing no figures the analyst can read yet. Each recommendation names the customer it is about and arrives as an ordinary task work item for the existing propose→confirm flow; a plan_only analyst publishes none of them. The role ships as catalog version 1.1.0: on an environment where 1.0.0 was already seeded, a superadmin must activate 1.1.0 in Platform → Roles before the hire wizard offers the Business Analyst.

MCP server connections (Systems)

#2746 — GET /crm/integrations now also serves the mcp_server provider, whose entry carries "kind":"system" so Settings can split Systems from channels; every channel entry keeps no kind at all. POST /crm/integrations/mcp_server/connect takes fields of server_name, server_url, auth_header and auth_secret. Only auth_secret is a secret: the whole target is sealed in the organisation's encrypted credential vault under COMM_CREDENTIAL_KEY and never returned, and the response carries only the masked credential_hint every connection read already has. The created connection's channel is mcp, its external_account_id is the address and its label is the display name. COMM_CHANNELS is unchanged, so POST /crm/communications/connections still refuses mcp with invalid_channel and an MCP connection can never accept an ingest submission.

The address is the whole SSRF boundary and is checked before anything is written: an address that is not https: on a public DNS host — a plaintext URL, an IP literal of any kind, localhost, a loopback, link-local or private host, a .local/.internal/.home.arpa name, embedded credentials, or a non-443 port — is refused 400 unsafe_server_url with no connection, no credential and no snapshot created. A reserved or malformed authentication header name is refused 400 invalid_fields the same way.

POST /crm/communications/connections/:id/test runs the MCP handshake (initialize, notifications/initialized, tools/list) and, on a pass, replaces this connection's tool snapshot and bumps its revision. The server's replies are untrusted data: a response over the byte cap or a tool list over the tool cap is refused whole with nothing stored, and tool names and descriptions are kept as bounded plain text. Every failure collapses to check.detail of the form mcp:<cause> plus at most a numeric HTTP status, where cause is one of unreachable, timeout, protocol_error, credential_rejected, response_too_large or tool_limit_exceeded. No server text, address or secret appears in a detail token, an audit row or a log line, and a failed check keeps both the stored credential and the previous snapshot so a corrected retry recovers the card with nothing retyped.

GET /crm/communications/connections/:id/tools returns items of {name, description} plus revision, tool_count and captured_at. It needs integrations:read and follows the collection convention its sibling /events read already uses: an unknown id, another organisation's id and a connection the caller may not inspect all answer 200 {"items": []}, so no id is ever confirmed. The organisation always comes from the verified session; a superadmin reaches another organisation only through the audited assisted-setup path. Each entry also carries the administrator's classification — see the next section.

POST /crm/communications/connections/:id/disconnect is the revoke: in the same request it retires the stored credential and deletes the tool snapshot, and records the disconnected audit event. It is final — a later check cannot reach the server and every later tool call is refused connection_revoked. Requires migration 0087_integration_tool_snapshots.sql.

External tools as governed actions (#2747)

A discovered tool does nothing until an administrator classifies it. GET /crm/communications/connections/:id/tools now returns, for each discovered tool, kind (read, effect, or null while unclassified), enabled_roles, revision and state — one of unclassified, classified or drifted. POST /crm/communications/connections/:id/tools/:toolName/classification needs integrations:write and takes {"kind":"read"|"effect","enabled_roles":[…]}; it answers 400 invalid_kind / 400 invalid_roles, and 404 for a tool the connection never reported or a connection in another organisation. Each write re-pins the digest of the tool as it stands — its name, its description and its input schema — and bumps revision. A re-discovery in which any of the three changed reads back as drifted, withdraws the published action, and answers an attempted call 409 tool_reclassification_required until an administrator classifies the tool again.

A tool that is classified and enabled for at least one role is published to that organisation's callers as external.<connectionId>.<toolName> in GET /assistant/v1/catalog and is invoked at POST /assistant/v1/actions/external.<connectionId>.<toolName> with a body of {"arguments":{…}}. A read needs crm:read and runs directly, answering {connection_id, tool, result_text, result_bytes, is_error}. An effect needs crm:write and an Idempotency-Key, and never contacts the remote system on invocation: it answers 202 awaiting_confirmation with a proposal naming the connection, the tool and the argument values, and the call is made once, on POST /assistant/v1/proposals/:id/confirm. Auto-confirmation never applies to an external effect. enabled_roles gates publication: a tool classified with an empty list is published nowhere — neither in the catalog nor in the Assistant’s tool list — and its id answers 404 action_not_found like any action the organisation does not have. A non-empty list publishes the tool to that organisation’s callers under the scope its kind requires; matching an individual caller’s role against the list is part of hire consumption and is not yet enforced here. The proposal record carries action set to the external action id, required_scope crm:write, and intended_external_call of {connection_id, tool_name, arguments}.

Refusals are named and reach no remote system. A tool whose remote definition changed since it was classified is withdrawn from the catalog and answers 409 tool_reclassification_required naming the tool until an administrator classifies it again. A paused, disconnected or revoked connection answers 409 connection_unavailable, and no other connection is ever tried. Invoking a read declares that it is a read, and the gateway re-reads the classification before it dials: a tool the organisation has reclassified from a read to a change since the catalog was read answers 409 external_effect_requires_human, because an outbound change is always held for a person's confirmation and is never executed on the caller's older reading. The same re-read also covers role enablement, refusing role_not_enabled; that refusal is reserved for calls made on behalf of an AI team member, because only those declare a role. A REST or MCP caller declares none, so it never meets role_not_enabled on these routes. Both refusals are decided before the stored credential is opened, so no remote system is contacted and no other connection is substituted. An action id belonging to another organisation is simply 404 action_not_found. Every remote-authored string is data: a tool name outside [a-z0-9_-]{1,64} is never published, descriptions and results are control-stripped and length-bounded, results reach the model only inside the untrusted-content envelope, and no remote text enters the system prompt or any authorisation decision. Every call is traced with the connection, the tool, the argument keys (never their values) and the result byte size; the credential stays in the vault and appears in no response and no log line. MCP and the CLI reach the same classified tools through the same published API (#2774); see the MCP manual for how a read and the propose/confirm/reject triad appear there. Requires migration 0088_integration_tool_actions.sql.

External tools during a hire's run

A hire's own run — Account Manager, Sales Assistant, Pre-sale Assistant, Customer Success Manager and Business Analyst alike — is a different caller from a REST or MCP client hitting external.<connectionId>.<toolName> directly: it declares its role and its hire's permissionLevel, so enabled_roles and the permission level both gate what it may use, and the run states what it could not use rather than silently doing less. v1 offers a hire only its organisation's classified read tools, in one bounded model round capped at maxExternalReadCalls — one shared ceiling for the run, not a figure any individual role carries; a classified effect is resolved like any other tool but is never callable by a run at any permission level, including auto_pilot — auto-pilot confirms Atlas's own prepared work, never an outbound change to a connected system.

A plan_only hire calls no external tool at all, read or effect: it only plans, so it makes no request to a connected system. A co_pilot or auto_pilot hire may call a read tool enabled for its role directly, with no confirmation, the same way it reads any other governed source. Everything excluded from a run — a tool not enabled for the role, an effect withheld regardless of permission level, a paused, revoked or reclassified connection, and two tools that would collide under the same published name — is summarised, never itemised, in the run's limitations: one counted sentence per reason, ordered role exclusions, then withheld effects, then unavailable connections, then name collisions, capped with the run's own other limitations at eight lines. No remote-authored tool name or description ever appears in a limitation line; the resolved and excluded action ids are recorded separately on the run's internal agent_team.external_tools trace step for audit, never surfaced by any read action and never on the customer-facing run page. When the organisation's classified-tool catalog itself could not be read, the run says so as one line and uses none of them, rather than guessing.

Current published releases (ekb.releases.*)

These read actions expose the Knowledge owner’s current policy and workflow release payload without projection. ekb.releases.list accepts optional kind (policy or workflow), q (2–100 characters), limit (1–50, default 20) and the returned opaque cursor. ekb.releases.read accepts one release_id. Citations, stable section or step locators, content, answerable and pagination are unchanged; a reference-only release remains answerable=false.

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.releases.list \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"policy","limit":20}'

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.releases.list \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"q":"quotation approval","limit":1}'

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.releases.list \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"q":"quotation approval","limit":1,"cursor":"eyJxIjoicXVvdGF0aW9uIGFwcHJvdmFsIiwia2luZCI6bnVsbCwidGllciI6MSwicHVibGlzaGVkX2F0IjoiMjAyNi0wOS0wOFQwMDowMDowMC4wMDBaIiwiaWQiOiJtcmVsXzAxIn0"}'

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.releases.read \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"release_id":"mrel_…"}'

A match preserves the typed locator, quote, content, and opaque next cursor. This abridged response shows the shape; a successful read returns the same complete payload under data.item:

{"status":"completed","data":{"items":[{
  "citation":{"reference":{"kind":"policy","owner":"ekb","id":"mrel_01","version":"3"},"asset":{"id":"mpol_01","title":"Quotation policy"},"locator":{"type":"section","stable_id":"psec_0123456789abcdef0123456789abcdef","label":"Approval"},"quote":"quotation approval is required"},
  "release":{"id":"mrel_01","release_no":3,"status":"released","answerable":false,"asset_kind":"policy","asset_id":"mpol_01","asset_title":"Quotation policy","content":{"title":"Quotation policy","category":"sales","sections":[{"heading":"Approval","body":"quotation approval is required"}]},"...":"unchanged owner fields"}
}],"next_cursor":"eyJxIjoicXVvdGF0aW9uIGFwcHJvdmFsIiwia2luZCI6bnVsbCwidGllciI6MSwicHVibGlzaGVkX2F0IjoiMjAyNi0wOS0wOFQwMDowMDowMC4wMDBaIiwiaWQiOiJtcmVsXzAxIn0"}}

No match is completed with data: {"items":[],"next_cursor":null}. Unknown, foreign, withdrawn or superseded ids return terminal_failure/not_found. Invalid input returns rejected/invalid_input. Failure responses never include release data:

{"status":"forbidden","error":{"code":"insufficient_scope","retryable":false}}
HTTP 403 {"status":"forbidden","error":{"code":"organisation_suspended","retryable":false}}
HTTP 503 {"status":"retryable_failure","error":{"code":"credential_store_unavailable","retryable":true}}
{"status":"retryable_failure","error":{"code":"credential_store_unavailable","retryable":true}}
{"status":"retryable_failure","error":{"code":"upstream_unavailable","retryable":true}}

Both entry admission and downstream failures use the action envelope; suspension returns HTTP 403, and authority-store or upstream outages return HTTP 503. Each call checks the token’s current membership, organisation state, role and intersection with ekb:read; Knowledge management permission does not add a read gate.

Source-use approvals (ekb.source-use.*)

Confirmation through REST or MCP uses an EKB POST assertion bound to this operation. It does not expose a general EKB write route, forward the bearer token to EKB, or bypass the current Knowledge permission and ekb:write checks.

The resolver read answers whether one exact current knowledge claim — a passage locator inside the source's active version — is confirmed and permitted for one customer. It returns the approval reference, the reason code, and a basis_digest that changes whenever the grounding changes (version replacement, retirement, withdrawal, expiry). An indexed source with no approval answers ineligible; indexing alone never permits disclosure.

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.source-use.read \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"source_id":"ks_…","page_no":1,"ordinal":2,"customer_id":"cus_…"}'

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.source-use.change \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "idempotency-key: approve-claim-x-1" \
  -d '{"operation":"approve","source_id":"ks_…","page_no":1,"ordinal":2,"audience":"customer_specific","customer_id":"cus_…"}'

Product-evidence selections (ekb.product-evidence-selection.*)

Named selections of pinned product claims — the reusable, inspectable basis customer-facing work drafts against (#2117). Reads are member-wide and org-scoped; writes need manager or administrator authority and the knowledge write level. Every entry resolves live: eligibility, excerpt and the customer-use permission come from the current source version and the ekb.source-use approvals.

# list the organisation's selections
curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.product-evidence-selection.read \
  -H "authorization: Bearer $ATLAS_TOKEN" -H "content-type: application/json" \
  -d '{}'

# read one with per-entry currency, excerpt and outward basis (optionally per customer)
curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.product-evidence-selection.read \
  -H "authorization: Bearer $ATLAS_TOKEN" -H "content-type: application/json" \
  -d '{"selection_id":"psel_1","customer_id":"cus_1"}'

# propose saving a selection (create / update / delete follow the same propose-confirm flow)
curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/ekb.product-evidence-selection.create \
  -H "authorization: Bearer $ATLAS_TOKEN" -H "content-type: application/json" \
  -H "idempotency-key: sel-key-0001" \
  -d '{"name":"Pump range Q3","entries":[{"source_id":"ks_1","page_no":3,"ordinal":1}]}'

Approving is a two-step write for managers and administrators (scope ekb:write): the proposal pins the claim to the current active version, and confirming after a version change answers conflict proposal_stale. Audience internal confirms a claim without permitting any external use; customers permits every customer; customer_specific permits one named customer and is validated against the organisation's own customers. Withdraw takes {"operation":"withdraw","source_id":"ks_…","attestation_id":"ksa_…"}.

Delegated token introspection applies the current role and permission matrix: ekb:write is effective only for a manager or administrator whose Knowledge permission remains write. Demotion or a read-only custom role removes this scope from existing tokens.

Auth health metadata: GET /auth/health adds auth.authorityManifest: {schemaVersion:1, environment, source_sha, source_ref, config_digest, semantics_digest}, or {available:false} when runtime configuration is invalid. It contains identities and digests only. This actual runtime descriptor does not prove registration, readiness or principal admission; no registration HTTP endpoint is added.

Customer-book continuity (owner only)

GET /crm/continuity?rep=<owner_email> lists one rep's active customers with per-customer coverage — profile level and missing codes against the actionable-profile standard, history (timeline entries and engagement stage history), evidence (notes and documents), open engagements with next-action coverage, and open tasks the owner's own visibility permits (open_tasks is {state:"visible_only",visible,limited} or {state:"source_unavailable"}; queried separately per customer; even zero visible tasks leaves private work unknown, without inferring its existence; limited means the 200-row cap was reached and the count is a lower bound; malformed responses and service failures are source_unavailable). GET /crm/continuity/customer?id=<customer_id> answers one customer's completeness detail: every requirement code states satisfied — with the records that cover it, each carrying the original author and a timestamp — or missing. Reps are matched on the customer book itself (owner_email), so a departed rep's recorded book stays reachable without an active-members roster, and no membership claim is invented. Both reads are owner-only (session owner flag): anyone else gets 403 owner_only; an unknown and a foreign-org customer id answer the same 404; the rep parameter must look like an email (400 invalid_rep otherwise). Browser-session only — no token action exists. (#2849)

Cited engagement context (crm.engagement.context.read)

One bounded, permission-checked, read-only customer context for later engagement work. The CRM pillar route is GET /crm/customers/:id/engagement-context?purpose=…&sections=…&limit=…&cursor=… (browser session), and the governed action carries the same input: purpose (follow_up or product_recommendation), optional sections (defaults to profile, contacts, opportunities, transactions, timeline), limit 1–100 (default 50 per record section), and an opaque cursor. Each section returns items, next_cursor and completeness (incomplete until the cursor is exhausted); every item carries an evidence_ref with owner, kind, id, a deterministic content-digest version, observed_at, confirmation and shareability.

curl -sS https://atlas.staging.forth.ai/assistant/v1/actions/crm.engagement.context.read \
  -H "Authorization: Bearer $ATLAS_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"customer_id":"cus_…","purpose":"product_recommendation","sections":["profile","transactions"],"limit":50,"cursor":"…"}'

→ {"customer_id":"cus_…","purpose":"product_recommendation",
   "sections":{"profile":{"items":[…],"next_cursor":null,"completeness":"complete"}, …},
   "gaps":[{"code":"ineligible_evidence_excluded","section":"profile"}],
   "next_cursor":null}

Pending, expired and revoked evidence never enters the items: it surfaces only as one generic gap entry per section, with no identifiers, titles, counts or dates. A customer outside the caller's organisation or rep scope is the same 404 as an unknown id, and the read performs zero writes and no model calls. Cursors are bound to one customer and purpose; replaying them elsewhere answers 400 invalid_cursor. The purpose is a lineage declaration, not a field selector — the target-customer field allowlist applies to every purpose, and any wider disclosure still requires an approved exact-content release.

Feedback attachment preview

Superadmins can read a stored feedback attachment at GET /governance/platform/feedback/{feedback_id}/attachments/{attachment_id}. The feedback and attachment identifiers are matched together, and the same authenticated Superadmin boundary applies to downloads and previews.

The default response uses Content-Disposition: attachment. Add ?preview=1 to request inline rendering. Atlas permits inline disposition only for the accepted JPEG, PNG, WebP, GIF, MP4, QuickTime, and WebM types. Documents, SVG, unknown types, and ordinary download requests remain forced downloads with X-Content-Type-Options: nosniff.

Feedback issues page

Superadmin queue: GET /governance/platform/feedback accepts combined org_id (exact organisation ID), sender (case-normalized exact email), type, status and handler filters. Whitespace around organisation/sender values is trimmed. The queue keeps filters and sorting in its URL, with table-only individual and select-all-visible checkboxes. Each queue row and the detail response also carry the submitting organisation's display name as org_name (resolved from the platform directory; null when it cannot be resolved, with org_id unchanged).

Attachment concurrency: Governance migration 0041_platform_feedback_mutations.sql must be applied before this Worker. A durable per-issue guard stores every planned private object key before any upload, including puts whose response may be lost. Parent creation and the upload guard are atomic; attachment metadata commit and guard release are atomic. Deletion claims the entire selection before removing objects. An active upload returns 409 feedback_mutation_in_progress without deleting any selected issue. Failed upload cleanup remains in upload_cleanup, and failed deletion remains in deleting; another confirmed Platform delete retries all retained keys before deleting records and the guard. Guard keys and ownership tokens are never exposed in list/detail responses.

Active uploading guards are never stolen or expired automatically: their invocation may still issue an R2 put. If the Worker is interrupted, or D1 is unavailable when its upload stops, the guard and exact keys remain and deletion stays blocked. Operations recovery must establish that the owning upload has ended before transitioning that guard to cleanup; elapsed time alone is not proof. For rollout, let previous-version uploads finish before enabling or exercising deletion, and retain this migration and its guards during any rollback. The ownership manifest for memory-family tables is unchanged; this table belongs exclusively to Governance.

After confirmation, POST /governance/platform/feedback/batch-delete accepts {"ids":["FB-2026-0042"]}: 1–100 unique non-empty issue IDs (letters, digits, underscores and hyphens; at most 128 characters). Single-issue deletion uses the same route. Verified Platform-superadmin authority and same-origin JSON are required; tenant membership alone grants no Platform reads or deletion. Success returns {"deletedIds":[...]}, containing exactly the deleted issues. Clients reconcile only those IDs.

Deletion validates the complete selection before cleanup. Private attachment objects are deleted before one atomic Governance transaction removes replies, attachment metadata and issues and records feedback.delete in the Platform operation audit for each issue, with verified operator, target organisation, issue ID in parent_request_id, request ID and source revision. Deleted issues disappear from Platform and submitter list/detail routes. There is no restore or submitter delete.

Errors: 400 invalid_selection for malformed, duplicate or oversized selections; 409 unknown_selection or selection_changed for unknown or concurrently changed targets; 409 invalid_attachment_metadata for unsafe object references; 503 feedback_cleanup_incomplete for storage/database failure. Cleanup can already have removed some files, and an uncertain response may follow a committed deletion. Never promise that the original issues or files remain fully available: refresh authoritative state, then retry only remaining IDs. On a failed database transaction, surviving parent/attachment metadata retains the keys needed to retry private-object cleanup.

POST /governance/feedback accepts an optional severity of p0, p1, p2 or p3 in both the JSON and the multipart form. An absent value stores p2; an out-of-list value is refused with 400 before anything is stored. Every stored item carries its severity, and the storage CHECK mirrors the allow-list.

A created issue's id is a readable ticket code, FB-<year>-<sequence> (for example FB-2026-0042), numbered per calendar year in submission order from an atomic counter; a number is never reused, even after the issue is deleted. Issues created before this change keep their fb_… identifiers, and every route accepts either form as an opaque string.

GET /governance/feedback lists the caller's submitted issues with status, severity and attachment metadata, scoped server-side to the verified session: managers and representatives read only their own submissions, organisation admins read every submission within their own organisation, and an empty result is 200 {"items":[]}. The caller's organisation, user and role never come from the request. Status transitions remain Superadmin-only platform actions.

GET /governance/feedback/{issue_id} returns one issue with its interaction thread — comments from the submitter (or an organisation admin) and from the Atlas team, plus every status transition, oldest first. The same #1990 scope predicate applies; an id outside the caller's scope is the same 404 as an unknown id. POST /governance/feedback/{issue_id}/replies with {"body"} (1–2000 characters) appends the caller's comment to that thread.

POST /governance/platform/feedback/{issue_id}/replies lets a Superadmin reply on the same thread, attributed to the operating Superadmin. A Superadmin status transition (POST …/status) now also appends a visible status-change entry to the thread in addition to the platform audit record. Historical transitions are not backfilled.

Run feedback read

GET /governance/run-feedback is the bounded, organisation-scoped read of assistant-reply Like/Dislike feedback events for the human-feedback workbench. It is separate from the caller-local GET /governance/feedback-status and never infers organisation-wide access from it. Every row carries the stable event id, source:"run_feedback", the original rating (liked or disliked), status (current for that author's latest event on that run, otherwise superseded), the attributed author, occurredAt, and the run anchor: {"id","anchor":"verified"}, {"id","anchor":"missing"} when the run row no longer exists, or null for a legacy event without a run anchor. Unanchored and missing-anchor rows stay readable to managers and admins but are never verified provenance; representatives never receive them.

Authorization follows the existing run-visibility paths: managers and admins read their organisation's feedback on runs their role may see; a representative reads feedback on anchored runs they authored and on the AI Team runs the hire rule below lets them read; team-surface and evaluation-linked runs remain behind live AI-team membership, exactly like /governance/runs. A hire’s run is further readable only by the people who can read that run in AI Team: the hire’s supervisor, whether a representative or a manager, or an organisation administrator, and for a run derived from an RFQ handoff only while its Pre-sale hire is theirs to see as well. An evaluation run of a hire follows the same hire rule. A team run that names no hire, such as a teaching-comparison replay, is readable only by an organisation administrator and the person who started it. The same rule applies to /governance/runs and /governance/trace-v2 reads and to the session label on a team run; a hidden run is left out of lists and answers 404 by id. Organisation, user and role always come from the verified session; query parameters asserting them are ignored.

Pagination is a keyset over the append-only event order: limit 1–100 (default 50), cursor = the last seen position from nextCursor. Pages never duplicate or skip a row that existed when the first page was served, and hidden rows never appear at any boundary. runId narrows to one run; an unknown or caller-invisible run answers the empty envelope, indistinguishable from no data. Errors: malformed cursor, limit or runId return 400 invalid_request; an unrecognised caller receives 200 {"items":[],"nextCursor":null}. The endpoint is read-only: it writes nothing, performs no triage, teaching, recall or publication transition, and copies nothing into Memory. Support feedback (/governance/feedback) is a different domain and is never consumed here.

Sandbox feedback read

GET /knowledge/sandbox-feedback is the typed, organisation-scoped read of Knowledge Sandbox answer ratings for the human-feedback workbench. It serves the same rows as the compatibility GET /knowledge/feedback list — org-scoped, every signed-in role — through an envelope that names the semantics: the stable id, source:"sandbox", the original rating (up or down, preserved verbatim), the stored triage status, the attributed author, occurredAt (epoch ms; null only when the stored instant no longer parses), the sandbox mode, the review content (question, answer, reason, note), the asked scope (kind, descriptor, label), and the cited sources, each carrying available resolved at read time: a deleted citation reads available:false, honestly marked and never silently rewritten or dropped.

Row visibility: a caller holding the knowledge write level (admin and manager by the default matrix) sees every row the organisation holds, including one whose citations name another member's personal document — the seeing triage requires. Every other caller never receives a row citing a document they cannot open.

Permissions are unchanged: organisation comes from the verified session and query parameters asserting org or actor are ignored. Pagination is the same keyset as the compatibility list — limit 1–100 (default 50, cursor from nextCursor) — and never duplicates or skips a row that existed when the first page was served. Malformed limit or cursor returns 400 invalid_request naming the offending parameter; an unrecognised caller gets the same 401 every Knowledge route answers. The endpoint is read-only: it writes nothing and performs no triage or correction — those stay on PATCH /knowledge/feedback/:id. Support feedback (/governance/feedback) is a different domain and is never consumed here.

Knowledge evidence references

POST /knowledge/evidence saves one cited location of a Knowledge document as a durable reference for customer work. Any signed-in role may save; the document must be visible to the caller and ready at save time — another member's personal document answers 404 not_found exactly like a missing id, and a document not yet ready answers 409 evidence_source_not_ready. The body is {"file_id":"…","passage_index":N} or {"file_id":"…","sheet":"…","range":"…"} (never both), with an optional note of at most 500 characters and an optional feedback_proof_id from the answer that produced the citation; when that proof still names the document's live generation, the reference binds to that exact generation. An optional Idempotency-Key header makes a retry after an uncertain response replay the caller's own reference; the same key from another member answers 409 idempotency_conflict. The stored row keeps the document's display id, generation, row version, locator and note — never document text and never the filename.

GET /knowledge/evidence/:id resolves one reference under the calling member's current access and answers {"item":{…,"status","label","excerpt","current_version"}}. available carries the live document name and the passage text at the locator (bounded to 480 characters; a locator that no longer lands reads excerpt:null, never a neighbouring passage); revised means the document was re-read since the save (version is the saved version, current_version the live one); processing names the document while it is being read again; unavailable carries no label and no excerpt — a deleted document, a replaced upload and access revoked by a rescope all read as this one state, without distinguishing them. A reference id from another organisation answers 404. GET /knowledge/evidence lists the caller's own references newest first with the same resolved status per row (limit 1–100, default 50, keyset cursor from next_cursor); rows whose sources became unavailable stay listed with status:"unavailable" and no source metadata. The surface is read-only beyond the save: references have no edit or delete verb, and saving a reference never approves a customer fact — the source-use claims remain the separate governed surface. Apply additive EKB migration EKB0048_library_evidence_references.sql before deploying the consumer.

Token configuration · Confirmation flow · Action and tool catalog

Customer profiles, imports and sampling stages

GET /crm/customers/:id returns transactions_summary.by_currency, one entry per recorded ISO currency with total_sales_cents and outstanding_cents. Missing or invalid legacy currencies use currency: null and must be shown as unavailable, never guessed; cancelled and refunded transactions remain excluded. Deprecated scalar totals are nonzero only for a single CNY group during rolling client upgrades; clients must read by_currency.

Customer create and update accept optional main_markets, brands and products, each up to 2,000 characters. Null clears; omission preserves. Profile reads include these fields under existing customer-owner authorization.

Excel/CSV mapping includes website, background, main_markets, brands, products and wechat, whatsapp, linkedin, facebook, instagram, other social columns. Social accounts require a contact name and allow 120 characters per handle. Blank optional update cells preserve stored values. Before confirmation, the page reports unmapped columns, excluded sheets and truncation beyond 1,000 rows.

POST /crm/settings/pipeline/stages, PATCH /crm/settings/pipeline/stages/:key, DELETE /crm/settings/pipeline/stages/:key and POST /crm/settings/pipeline/stages/:key/move allow admin, organisation owner and manager. Representatives cannot mutate the pipeline. DELETE removes a stage outright instead of hiding it: it answers 409 with the affected count while deals still sit in the stage, and the last active Won and Lost stages can never be removed. Untouched default catalogues gain sample_sent and sample_evaluation after quotation and before negotiation. Customised catalogues remain unchanged.

Customer directory: the prospect split is retired

The directory lists every customer type again, prospects included (#4200, reversing #4158). GET /crm/customers and GET /crm/customers/stats no longer accept exclude_customer_type: the parameter shipped for one staging day and is removed, and an unknown query parameter is ignored as it always was. Filter the directory to one type with the existing customer_type=<key> parameter, validated against the caller’s own organisation catalogue — an unknown key still answers 400 invalid_customer_type. A prospect is a customer whose customer_type says so; there is no separate prospect collection, and no route was removed.

Private collection saved views and deterministic collection sorts (STORY-V2-ERP-01)

GET /crm/collection-views?collection=contacts|companies|deals|tasks returns the caller's own saved views for that collection as 200 {"items":[…]}; an empty result, another member's list or another organisation's list are all the same {"items":[]}. A view is configuration only — search text, validated filters, sort, visible column order and supported display mode — never copied business data. Applying a view re-issues the collection query, so record visibility is re-evaluated on every request.

PUT /crm/collection-views stores a view: body {"collection","name","query":{"text","filters":[{"field","operator","values"}],"sort":{"field","direction"}},"columns","mode","existing":{"id","revision"}?}. Create answers 201 {"item":…}, update 200 {"item":…} with the next revision. Every field, operator, sort key, column and mode is validated against the per-collection whitelist first: an unknown or retired setting is refused with 400 {"error":"invalid_view","field","reason"} naming the exact setting — never silently dropped or broadened. A duplicate name for the same user and collection answers 409 view_name_conflict; updating with a stale revision answers 409 view_revision_conflict; an existing id belonging to anyone else is the same 404 not_found as an unknown id. Each user may hold at most 100 views per collection (409 view_limit). Allowed filters: contacts customer_id (eq), role (eq primary|regular); companies status,country,customer_type,customer_class,engagement,owner (eq); deals stage (in), tier (in), owner (eq), close_date (gte/lt, YYYY-MM-DD); tasks status (eq open|done), assignee (eq), due_at (gte/lt, YYYY-MM-DD). Board mode is valid only for deals.

The three CRM collection lists gain a deterministic caller-selected sort: GET /crm/contacts?sort=<field>:asc|desc (updated_at,name,email,title,customer_name), GET /crm/customers?sort=… (updated_at,name,country,customer_class,created_at) and GET /crm/engagements?sort=… (updated_at,title,close_date,amount). An unknown or malformed sort answers 400 {"error":"invalid_sort"}. Omitted, the historical default order applies; in both cases equal sort values break ties on the stable record id, so pages never repeat or lose a row, and total remains the same query's unpaginated count. The Tasks collection's query producer remains with the Tasks service; saved task views persist here and execute once that producer ships.

Batched customer display names (BUG-158)

GET /crm/customers/names?ids=<id> resolves display names for label fallbacks in one request: repeat the ids parameter (or separate ids with commas) up to 100 unique ids. The answer is 200 {"items":[{"id":"…","name":"…"}]} holding only the caller’s organisation customers that exist — a deleted, foreign or never-existing id is simply absent, never a per-customer 404 — and visibility mirrors the per-id read exactly: a representative resolves only their own assigned book, and pooled rows have left it. An empty id set answers 200 {"items":[]}; more than 100 ids answer 400 too_many_ids; unauthenticated callers are refused by the session boundary like every CRM route.

Customer tier retires; type, class and label-set filtering (#4224)

The tier dimension is retired from the customer REST surfaces: it was never visible in the UI and every row stores the standard column default, so filtering on it matched nothing but standard. GET /crm/customers no longer accepts ?tier=, and PATCH /crm/customers/:id no longer accepts a tier field — a request still carrying either is ignored, exactly like any other unrecognized query parameter or body field, and the stored column keeps its default until a later release removes it. Customer creation and import no longer store a caller-provided tier. Filter the directory with the existing customer_type, class and label_set parameters. The engagement-plan subsystem is unchanged: GET /crm/engagements?tier= and the outreach-playbook match.tier conditions keep their tier surfaces.

GET /crm/portfolio takes the same three filters, implemented server-side with the customers-list semantics: customer_type=<key> and class=<key> are validated against the caller’s own organisation catalogues — an unknown key answers 400 invalid_customer_type or 400 invalid_customer_class — and label_set=<id> restricts the book to customers with at least one applied value from that set, resolved only through label sets the caller can see, so an unknown or foreign id simply matches nothing. The portfolio’s former single-valued tier filter is removed, and each returned customer row names customer_type and customer_class instead of tier.

Task privacy

Self-created, self-assigned tasks default to private, including tasks linked to a customer. Only their assignee can access them; managers and admins have no override. A team task is visible to its assignee, creator and organisation management, never unrelated representatives. These boundaries also apply to filters, source retries, reminders and agent calls. Scheduled shared context excludes private tasks.

The signed-in application task API accepts visibility: "private" | "team" on POST /tasks. Assigning another member requires manager or admin authority (owners have admin authority) and team visibility. GET /tasks?assignee_email=EMAIL only narrows authorized results and returns 200 {"items":[]} for no accessible match; inaccessible task IDs return 404. Visibility is immutable after creation; private tasks cannot be reassigned. Only management may reassign a team task. This session API is separate from the governed bearer-token action API above.

Private document sharing suggestions

A signed-in rep with file-contribution permission can propose their own private Library file for a concrete Company docs folder in the same organisation. This grants authorised reviewers access only to an immutable submitted snapshot, not to the private Library file. Approval requires the current tenant owner/admin/manager role and knowledge-write permission. Delegated tokens are not supported.

POST /knowledge/library/proposals requires Idempotency-Key and {"source_file_id":"…","destination_folder_id":"…","consent":true,"consent_version":"1"}. It returns {item}. GET /knowledge/library/proposals?view=mine|review&limit=30&cursor=… returns {items,pending_count,failed_count,next_cursor}; the limit is 1–100. Every item carries result_status — the approved Company copy’s live processing state (draft, extracting, ready, failed; null before approval) — joined org-scoped from the copy file. view=review also returns failed_count: approved proposals this reviewer approved whose copy ended failed; a successful retry of the copy clears it. Unauthorised collections are empty. GET /knowledge/library/proposals/:id returns a visible proposal. GET /knowledge/library/proposals/:id/content downloads its pending snapshot; terminal snapshots and invisible records return 404.

POST /knowledge/library/proposals/:id/approve|reject|withdraw requires Idempotency-Key and {"expected_revision":1,"reason":"optional, at most 500 characters"}. A rep can withdraw their own proposal; reviewers approve or reject. Reuse the same key and payload after an uncertain response. Approval atomically creates an independent Company copy, records its result_file_id, and, only when the copy is at most 50 MiB and within reader limits, processes that copy as POST /knowledge/files/:id/confirm does, through the governed AI gateway and attributed to the approving reviewer, so a supported copy can reach ready without a second manual step. Larger copies remain uploaded, not processed; sharing approval does not authorize large-file processing. The personal original is retained. The approval is recorded before processing starts: a processing failure never reverts the approval, removes the copy or turns the approval response into an error — the copy is left draft (Confirm) or failed (retry through POST /knowledge/files/:id/retry on result_file_id), and result_status reports that state. Replaying the same approval key returns the recorded outcome without processing again. A reviewer without file-contribution write still gets the approval recorded with the copy left draft. Video files (MP4, MOV, WebM) skip extraction: the copy stays draft for Library video processing and is never counted as failed.

Statuses are preparing, pending, approved, rejected and withdrawn. Changed source/destination, duplicate Company file and concurrent decisions fail closed: source_changed, destination_changed, duplicate_file, revision_conflict, decision_conflict or operation_in_progress. Refresh before deciding again. Quotas reserve snapshot and future-copy bytes; retries recover interrupted copies without sharing the private storage object. Operators must apply additive EKB migration 0029_library_document_proposals.sql before deploying the consumer.

Large-library duplicate checks may return 409 verification_in_progress with verification: {verified,total} and Retry-After: 1. Counts cover same-size Company files. Retry the same approval key and revision to resume durable progress; no copy has been approved yet. A Company inventory change safely restarts verification. Deploy Auth before EKB and configure the EKB AUTH Service Binding to its matching environment: each proposal read or decision checks current tenant membership and permissions through Auth.

Background document reading

These reading routes apply only within the processing size bounds; stored-only large originals do not enter the queue.

POST /knowledge/files/:id/confirm (a draft) and POST /knowledge/files/:id/retry (a failed file) no longer read a text-family, Word (.docx) or PowerPoint (.pptx) document inside the request. They answer 200 at once with the usual {item} detail, now status: "extracting" with extraction_stage: "queued", and queue one reading job for that file version on EKB's queue (migration 0042_library_extract_jobs.sql). While a file is extracting, GET /knowledge/files and GET /knowledge/files/:id carry extraction_stage: queued, reading, describing_images or indexing (null when no current job names one); other rows omit it. Poll the detail until status is ready or failed.

The job publishes the same passages, summary and keywords the in-request reader produced and then queues the file's embedding job. A document keeps up to 2,000 passages and at most 1,500,000 bytes of stored passages (Office: passages plus the stored reading at most 1,800,000 bytes); beyond that the head of the document is kept. A reading that fails on its content fails at once with its class (empty_file, decode_failed, unreadable_office); a transient failure is retried, and the third failed attempt marks the file failed with its class (for example storage_error, extraction_stalled), retryable with POST /knowledge/files/:id/retry. A job for an older file version, or for bytes other than the ones it was queued for, writes nothing. PDF, image and spreadsheet files are still read inside the Confirm request and answer ready or failed as before; a proposal approval still processes its copy in the approving request.

Upload large originals without automatic processing

Library, My Docs, Assistant file drops and customer Documents accept their existing supported original file types up to 1 GB (1 GiB; 1,073,741,824 bytes), inclusive. This is a storage limit. Files larger than 50 MB (50 MiB; 52,428,800 bytes) remain uploaded, not processed: no automatic parsing, extraction, embedding, association reading or model calls. Files exactly at 50 MiB keep existing automatic behavior and reader limits; upload success does not guarantee readable content.

The direct POST /knowledge/files and POST /crm/customers/:customerId/documents form uploads retain their 25 MiB (26,214,400 bytes) ceiling. Above it, use the bounded multipart routes below. Each full part is 16 MiB (16,777,216 bytes); the final part contains the remainder. A 1 GiB original requires 64 parts. Send raw part bytes, not base64 or a whole-file JSON body.

Library routeRequest and result
POST /knowledge/files/multipartJSON {name,size,scope?,folder_id?}, with Idempotency-Key. Size is the original's byte count; scope is personal or company under existing permissions. Returns {uploadId,partSize,partCount}, or {item} for an already completed upload. All existing allowed original types can use this route.
PUT /knowledge/files/multipart/:uploadId/parts/:NRaw bytes for the one-based part number. Send every part, in contiguous coverage, with the declared size.
POST /knowledge/files/multipart/:uploadId/completeUse the original Idempotency-Key. Validates the assembled bytes, deduplication and final quota before returning {item}.
DELETE /knowledge/files/multipart/:uploadIdCancel an unfinished upload. Cancellation preserves completed originals.
Customer routeRequest and result
POST /crm/customers/:customerId/documents/multipartJSON {name,size,content_type?,kind?,note?,propose?,engagement_id?,deal_answer?,pre_atlas_ref?}, with Idempotency-Key. Returns 201 {uploadId,partSize,partCount}; a completed replay returns 200 with the bare customer document (no storage key). Customer, document-kind and deal-answer permissions and validation are unchanged; propose does not override processing size limits.
PUT /crm/customers/:customerId/documents/multipart/:uploadId/parts/:NRaw bytes for the one-based part number, using the returned part size.
POST /crm/customers/:customerId/documents/multipart/:uploadId/completeUse the original Idempotency-Key. Returns 201 with the bare customer document after server validation; completed replay returns 200.
DELETE /crm/customers/:customerId/documents/multipart/:uploadIdReturns 200 {cancelled:true} without removing a completed document; busy operations return 409, retained cleanup failures 503.

Keep the same authenticated member, upload identity, original bytes and idempotency key when retrying an uncertain request. Upload state is bound to the organisation and member; never supply another organisation's identity. A refused type, invalid part coverage or size mismatch creates no completed document. Above the original limit the server refuses with 413 too_large; Library quota refusals use 413 quota_exceeded. The Library organisation quota remains 2 GiB (2,147,483,648 bytes). Failed partial-upload cleanup retains recovery state. After a lost completion response, inspect or replay the same upload before starting another.

Only the existing explicit MP4, MOV and WebM video readiness flow supports large-file processing, subject to its duration, codec and device limits. Large nonvideo files have no enabled Process action: TXT/MD/CSV and PDF readers remain limited to 25 MiB, images to 8 MiB, Office/XLSX to 32 MiB and customer proposals to 10 MiB. Confirm, Retry, proposal approval, queue recovery and background reads cannot bypass these bounds or grant automatic consent. A copied large original remains stored-only until its own supported explicit processing. Storage permissions, billing policy, feedback/email attachment limits and import budgets are unchanged. CRM returns 409 upload_cancelled for a cancelled receipt; retry with a fresh key only then. A crashed CRM completion becomes resumable after its 120-second lease: init returns completionOnly:true with the part plan, and the client calls complete without resending parts.

Browser-local video AI Readiness

For originals larger than 50 MiB, the member reselects the original video file from their device before confirming processing. The browser hashes bounded slices; the server verifies the checksum against the stored source. A mismatching file cannot authorize a different source.

POST /knowledge/files accepts MP4, MOV and WebM up to the 25 MiB direct-upload limit; use multipart upload for originals up to 1 GiB. A video remains a draft, unprocessed original; generic confirmation does not run video inference. The signed-in contributor starts processing explicitly from the Library. No model inference runs on the server for this path, including embeddings.

All routes below are scoped to the authenticated organization and the existing file-contribution permission. The browser computes the source SHA-256 and uses the exact compiled pipeline manifest. Every write is bound to the stored source revision, checksum, skill identity and run nonce; a nonce does not grant cross-organization access.

RouteRequest and result
POST /knowledge/files/:id/video-readiness/startJSON {sourceChecksum,pipeline,replacesRunId} with Idempotency-Key. Returns run containing runId, nonce, sourceChecksum and exact skill. An idempotent retry preserves run identity. First start sends replacesRunId:null; recovery explicitly names the unfinished run observed from status to replace it.
POST /knowledge/files/:id/video-readiness/segments{runId,nonce,ordinal,segment:{kind,start,end,text,language}}. The kind is transcript or frame; times are seconds. Repeating the same ordinal and evidence is idempotent; conflicting evidence is refused.
POST /knowledge/files/:id/video-readiness/finalize{runId,nonce,pipeline,metadata,coverage,durationMs}. Validates bounded persisted evidence and source identity before publishing AI readiness. Metadata includes durationSeconds,width,height. Coverage declares available/captured/total audio and sampled/planned frames; sampled coverage is never exhaustive.
POST /knowledge/files/:id/video-readiness/cancel{runId,nonce,reason,durationMs}. Stops an unfinished run without publishing readiness. A completed result is not undone.
GET /knowledge/files/:id/video-readiness/statusReturns {readiness:{state,runId,reason,result}}. Results include source: "browser_reported", pinned skill/pipeline identities, timestamps, metadata and coverage.

Only a complete persisted result is AI-ready. Processing is limited to five minutes, 240 evidence segments and 24 sampled frames. Unsupported codecs/devices and interrupted work retain the original. Browser-local transcript and caption output is untrusted derived evidence, not server-attested inference; it remains subject to permission checks and safe rendering. Quality reports preserve unknown usage instead of inventing zero cost.

Frame extraction is bounded to a maximum edge of 1,280 pixels, 921,600 pixels per frame, JPEG quality 0.8, 512 KiB per frame and 6 MiB in total. Coverage reports planned and retained samples separately. Reusing a start key with a changed actor or replacement intent returns 409 idempotency_conflict.

Retrospective activity-level changes (#3340)

GET /crm/retrospectives/current and GET /crm/retrospectives/:id may now carry an additive metrics.extensions.activity_changes envelope (schema v1) in the stored metrics. It lists the activity-level transitions actually applied during the retrospective period, each with the recorded from/to levels, committed timestamp, source (sweep for automatic application, confirm for human-confirmed, with finder and confirming actor distinguished), the standard version exactly as recorded at application time, and the recorded reason inputs. Pending or dismissed proposals are never transitions, and no historical row is ever reclassified against today's standard. Owner, ownership revision and customer class are null until a future producer snapshots them, so every row is organisation-observed evidence with attribution:"unattributed": rep-scope retrospectives report coverage partial with reason owner_unavailable and no items, while manager team retrospectives list the organisation's observed events without per-rep totals. Coverage is complete only when the organisation's sweep-run history proves a gap-free capture interval across the period; only then may summary carry scope-wide {applied, automatic, confirmed} counts. Unreadable sources freeze the extension unavailable; retrospectives stored before this extension shipped omit it, and readers ignore unknown additive fields.

Retrospective focus-customer handling (#3339)

GET /crm/retrospectives/current and GET /crm/retrospectives/:id may now carry an additive metrics.extensions.focus_handling envelope (schema v1) in the stored metrics. It shows how the focus customers actually published during the period were handled. The assessment set is the union of customers in any published focus revision per working day, deduplicated by subject rep, working day (in the organisation's timezone) and customer; manual promotions are marked separately from algorithmic selection. Each item carries the working day, the period's first inclusion timestamp, the brief revision ids that contained the customer, the decisions cited to those revisions, the distinct accepted task source ids (retry accepts sharing a source id count once), the latest disposition ordered by commit time, the linked task's state at the end cutoff, done-event references for work completed inside the period, and separate actors for the assigned rep, the accepting actor(s) and the completing actor. Edited, dismissed and accepted decisions stay distinct; acceptance never becomes completion; a customer-day without a recorded decision is unknown, never ignored; and no combined "handled" rate is calculated. Tasks evidence comes from the Tasks pillar's own history projection, with private tasks answering unknown and no metadata. Decisions recorded in this period about prior-period customer-days appear as carry-over rows (carry_over:true) with the prior working day, a null first inclusion timestamp and no summary count — evidence of an earlier period's obligation, never a new denominator. Coverage is complete only when both producers prove a gap-free window; only then may summary carry {customer_days, decision_recorded, accepted_work, completed_by_end}. Unreadable or unpermitted sources freeze the extension unavailable with no rows; retrospectives stored before this extension shipped omit it, and readers ignore unknown additive fields.

Retrospective plan adherence (#3338)

GET /crm/retrospectives/current and GET /crm/retrospectives/:id may now carry an additive metrics.extensions.plan_adherence envelope (schema v1) in the stored metrics. It compares the period's accepted planned touches with their evidenced execution, read from the frozen crm.plan-history.v1 and crm.schedule-history.v1 projections. Three-touch obligations and schedule occurrences are separate groups, never summed, and no ratio, rate or ranking exists anywhere in the envelope. Each row carries the source group, the immutable obligation and accepted revision ids, the customer, the owner snapshot taken at acceptance beside the distinct executing actor, the source's own local due date with its timezone and the due day's start instant, the disposition (due, excluded_before_due with its excluding event reference, or carry_over for prior-period obligations evidenced in-period, which stay out of the due denominator), the execution state (three-touch: recorded_completed/no_linked_completion; schedule: provider_accepted, never called delivered, failed or unknown), and references for the permitted supporting records. Unaccepted proposals, refreshed suggestions, drafts and unlinked completions are never counted; a completion after the period end never touches the saved result. Coverage is complete only when both producers prove a baseline and gap-free admitted evidence over the whole period; only then may summary carry the per-source counts (due, excluded_before_due, carry_over, recorded_completed for three-touch, provider_accepted/unknown for schedules, inapplicable fields null). A missing or pre-capture baseline is at best partial with the observed rows and no totals, or unavailable when nothing is observable; Schedule rows carry the owner snapshot taken at acceptance from the owning basis view; owner_unavailable names only rows whose basis is absent from the journal or was recorded without a snapshot. Gaps, truncation and unresolved owners are partial. A schedule basis accepted after the due day's local midnight leaves the occurrence excluded before due, with the divergence recorded in the row's evidence references. Unreadable sources freeze the extension unavailable with no rows and never affect the base metrics; retrospectives stored before this extension shipped omit it, and readers ignore unknown additive fields.

Customer classification criteria and reviewed changes (#3336)

Settings → Sales & CRM → Customer Classification combines the existing class catalogue with measurable criteria and individual review. GET /crm/classification/values requires labels:read; catalogue and policy writes require labels:write. The existing POST /crm/classification/values, PATCH /crm/classification/values/:id and DELETE /crm/classification/values/:id continue to create, rename, reorder and delete bilingual classes. PATCH also accepts {swap_with, expected_order, other_order} for an atomic same-kind swap; stale positions return 409 stale_catalogue. Deletion of an active policy target returns 409 classification_policy_in_use: edit or disable the policy first, then choose a same-kind replacement if customers still carry the class. Lost and the final value remain protected. POST and PATCH also accept color (a palette key: slate, red, orange, amber, green, teal, blue, violet, pink; PATCH accepts null to clear); any other value returns 400 invalid_color.

GET /crm/classification/metric returns {item} or null. PUT /crm/classification/metric accepts {version, name, source, unit, year, ranges:[{value_id,min,max}]}. value_id is the stable class key, not its row ID; targets must be existing non-Lost classes in this organisation. Version 0 creates a policy; edits require the returned version. DELETE /crm/classification/metric accepts {version} and disables the policy. Revisions never reset. Empty, duplicate, overlapping, invalid and unavailable targets are refused. Lower bounds are inclusive, upper bounds exclusive; null means unbounded, gaps mean no match.

Sources are paid_sales, company_revenue, business_size and custom. Paid sales sums paid transactions only, in major currency units, in the exact ISO currency and UTC calendar year; missing evidence is not zero and currencies are never converted. Other sources require PUT /crm/classification/metric/observations/:customerId with {version,value,source_note}; the source note must identify the evidence for the current policy, unit and year.

GET /crm/classification/metric/preview?limit=25&after=customerId returns {metric,items,next_cursor}; limit is 1–50. Items include customer identity, current_class, measured value, source_note, suggested value_id, status (match, no_match or insufficient), applied and evidence_token. Paid-sales evidence also includes paid_sales_evidence:{transaction_count,currency,year} for localized presentation, or null when absent; the immutable source note and token remain unchanged. Only accessible active customers are included; reps see assigned customers they own. Unit/year are explicit in the returned metric. Lost customers receive no suggestion.

POST /crm/classification/metric/apply accepts {version,customer_id,value_id,evidence_token}. It revalidates labels-write authority, customer access, policy, source revision, customer state and target in an atomic batch. A changed review returns 409 stale_review; a consumed confirmation never reapplies after a manual change. Successful application updates only that customer class and records immutable reviewer, time, policy and evidence provenance. No scheduler or automatic reclassification is introduced. Manual selection, Lead/first-Won/Lost behavior and directory filters remain intact. The legacy /crm/settings/classification dimensions API remains compatible and does not evaluate criteria.

Policy list release status

GET /knowledge/policies includes additive current_release_no on each item: a positive release number only when the organisation has a valid current published release, otherwise null. The mutable library status can still be Draft while a prior release remains current; a legacy Published status without a release remains reference only. The value is read in the same organisation-scoped list query and is cleared when the current release is withdrawn.

Policy and workflow releases

Managers and admins publish an immutable release of a Knowledge Library policy or workflow draft; every signed-in role can read the result. Publication snapshots the draft's stored content bytes, records the author, timestamp, hex SHA-256 content digest and stable per-section/per-step identifiers, and records a dependency snapshot of the same-organisation links at that moment. A declared linked source must still exist in that organisation when publication commits; a missing or foreign link returns the same 404 as an unknown asset and creates no release. The publication transaction repeats the source check so a deletion race cannot activate content. Editing the draft never changes a published release. executable publication is refused with 400 not_eligible until step validation ships; a release is reference-only by default and answerable only when the publish request sets it.

POST /knowledge/policies/<id>/releases      (same for /knowledge/workflows/<id>/releases)
{"base_version": 7, "answerable": true}          # base_version is REQUIRED
{"base_version": 7, "restore_from": "mrel_…"}    # rollback: a NEW release from old bytes

→ 201 {"item": {"id": "mrel_…", "release_no": 3, "published_at": "…",
        "author_email": "…", "status": "released", "content_digest": "…",
        "answerable": true, "executable": false,

Customer pool

An organisation can pool a customer to take it off its owner's assigned book without erasing history. POST /customer-pool/:id/release (the current effective owner, or a manager or admin) marks the row ownership_state=pooled, retains the prior owner in owner_email and previous_owner_email, and records the reason. POST /customer-pool/:id/claim lets any caller claim a pooled customer for themselves; POST /customer-pool/:id/assign is manager/admin only and names an assignee_email. Claim and assign reset the customer to assigned under the new owner.

Every mutation carries expected_revision and request_id. A stale expected_revision answers 409 revision_conflict with no partial write. Repeating a request with the same request_id for the same customer returns the recorded outcome (200 with replayed:true) and never inserts a second audit row. Each effect writes one immutable row into the transition audit (release, claim, assign, or patch_assign) — there is no update or delete path.

GET /customer-pool is available to every role and lists the org's pooled customers with a limited projection only: id, name, country, industry, previous_owner_email, pooled_at, pool_reason and the control field ownership_revision (the optimistic-concurrency guard claim requires). The optional q parameter (at most 100 characters) matches case-insensitively against only those fields, in SQL before pagination, and drives both items and total; pagination uses limit (1–100, default 25) and offset. GET /customer-pool/:id returns the same projection plus open_engagements and open_quotations counts from CRM's own records; open_tasks is reported as null because the tasks pillar exposes no customer-scoped count this pillar may call.

While pooled, the customer disappears from its former owner's portfolio and every customer-scoped read for that rep — the answer is the same 404 as a missing id. Manager and admin org-wide visibility is unchanged. Independent opportunities, quotations and tasks are never reassigned by a transition. Every NEW ownership effect — claim, assign, and a PATCH /customers/:id that changes owner_email — also passes a live Auth membership check immediately before the write; a missing binding, a failing call or an inactive member refuses the move fail-closed. The proof minted by Auth remains required for PATCH owner changes, but it never replaces the live check. A representative PATCH rechecks that the customer remains assigned to that representative at transaction effect time. A PATCH combining fields with an owner change writes its customer fields, denormalized records and immutable transition audit in one revision-guarded transaction; a lost race returns 404 for a representative or 409 revision_conflict for management, with no partial write. Same-owner PATCHes and customer creation are unchanged.

Customer pool policy and automatic recycling

Each organisation can configure when assigned customers automatically fall into the customer pool. GET /customer-pool/policy (admin only) returns the current policy: it is enabled:false with inactivity_days:60, warning_days:7 and assignment_grace_days:60 until an admin activates it. Settings are bounded — inactivity and assignment grace 1–3650 days, warning 1–30 days and strictly less than inactivity — and every organisation's policy is independent.

POST /customer-pool/policy/preview (admin) simulates proposed settings without changing anything: it returns qualifying_count (assigned customers past the inactivity threshold and not protected), excluded counts (protected_count, recent_contact_count), per-customer open_engagements / open_quotations counts, each item's warning_due_at and projected_release_at, and the current policy_revision. Only canonical human-recorded customer communications — a logged call or meeting, a human-confirmed inbound message, a human-approved send — postpone the deadline; edits, internal notes, uploads, task completions and unattended automatic activity never do.

POST /customer-pool/policy/activate (admin) stores the settings and enables recycling. It requires expected_revision — the revision returned by the preview — and a stale value answers 409 policy_revision_conflict, so two admins cannot activate against different assumptions. Deactivation uses POST /customer-pool/policy/deactivate with the same revision guard; a disabled policy performs no automatic action. Activating restarts the activation safety grace: nothing becomes releasable until a full warning period has passed since activation, so old imported customers are never recycled immediately.

A manager or admin can hold one customer out of recycling with POST /customer-pool/protections (customer_id, protected_until at most 365 days out, a 5–500 character reason) and release the hold with POST /customer-pool/protections/:customer_id/remove. Both changes write one audited timeline entry each. GET /customer-pool/protections lists the active holds.

When the policy is enabled and a customer reaches its warning deadline, one in-app warning goes to the owner and every manager and admin through the tasks pillar (never any external message). The warning is deduplicated per ownership revision and deadline and retried until delivered; release then waits until every gate has elapsed — inactivity, assignment grace (anchored at the later of the last assignment and activation), the activation safety grace, no live protection, a delivered warning and a full warning period since its delivery. The automatic release atomically re-checks the current contact, ownership revision, policy, protection and warning before it applies, so a conflicting change leaves no transition or audit effect. The bounded sweep keeps durable organisation and customer cursors and wraps fairly, so a busy early page cannot starve later eligible records. It records the policy revision and reason on the immutable transition audit, and a disabled policy performs no transition.

Customer ownership handover

Bulk handover lets the organisation owner move many customers to one recipient at once. POST /customer-handover/preview takes assignee_email and 1–100 customer ids and answers, per customer, the current and proposed owner, the ownership state and revision, and the permitted open-work counts (open engagements and quotations from CRM's own records; open_tasks is null), plus the live recipient-eligibility answer and the required reason bounds. Preview never writes: no ownership change, no revision bump, no audit row. A missing id is reported as missing:true — never dropped, never invented.

POST /customer-handover/confirm takes assignee_email, a 5–500 character reason, and 1–100 entries of {id, expected_revision, request_id}. Both routes are reserved to the organisation owner; managers and admins keep #2363's single-customer pool assign. Confirm applies the same atomic revisioned transition as the single PATCH owner change, once per customer, and always answers with one result per customer: transferred (with the resulting revision and state), conflict (revision_conflict, ineligible_recipient or already_owned_by_recipient), or failed (not_found). One refusal never undoes an unrelated success. Recipient eligibility is re-checked live immediately before every effect, so a mid-batch change refuses only the moves after it. A retried request_id returns its recorded outcome before any refusal is computed, so a partially completed batch can be re-sent without duplicating a transition; unresolved customers simply re-execute. Pooled customers may be handed over directly — the move assigns them and clears the pool state. Each effect writes the same immutable audit row (actor, reason, original and resulting ownership); independent opportunities, quotations and tasks are never reassigned, and former agent authority on the customer is revoked with the ownership, never inherited.

Handover recipient suggestions

POST /agent/handover/recipients is reserved to the organisation owner (the session's owner flag; every other principal answers 403 owner_only with no data). The body carries customers: 1–100 entries containing an id for the customers being prepared for handover; older clients may also send display fields, which are ignored. The agent obtains live active membership from Auth and re-reads every customer's name, current owner, recorded authorship and timestamps from the owner-authorised CRM continuity route, with the caller's cookie and role preview forwarded. It intersects that roster with CRM's recorded workload and ranks at most three members only when both selected-customer relationship records and workload evidence are available. Missing private or external workload stays unknown rather than zero. A missing or foreign customer answers the same 404 customer_not_found, and unavailable Auth or CRM evidence answers 503 without a model call. Suggestions are advisory: they never move ownership, and the handover preview/confirm re-runs live eligibility regardless of what was suggested. The response retains its readable gaps, rationale and evidence label/value fields, and may add gap_codes, rationale_code, and per-evidence code, count or at for localised rendering. A client translates only codes it knows; an unknown code must show its accompanying readable field.

Handover completion

After the owner's bulk handover moves customers (POST /customer-handover/confirm), a transferred customer's handover stays pending completion until it has a qualifying next action. Everything in this section is reserved to the organisation owner (the session's owner flag — not the tenant role); anyone else answers 403 owner_only with no data, and the organisation is always the verified session's.

GET /customer-handover/completions lists only the verified organisation's current handover transitions, per customer: the request, previous and resulting owner, reason, time, current owner, and that transition's pending or complete record (recorded next action, task-creation failure, and when complete, actor and time). Historical receipts remain immutable and can be replayed only with their explicit handover_transition_id; they never label a later transfer complete. Customers without a current eligible handover are omitted from this list.

POST /customer-handover/next-action records the transferred customer's next action and creates exactly one governed task for it through the signed CRM→TASKS binding. The body carries customer_id, a 1–200-character title, an explicit epoch-seconds due_at, an optional assignee_email (defaulting to the customer's current owner) and a request_id. Before every local and task effect, the recorded handover transition must still match the customer's assigned owner and ownership revision; a customer without that current audited transfer answers 409 no_current_handover, and a superseded pending transfer answers 409 handover_not_current. The assignee must pass a live Auth-owned active-membership check immediately before the effect (fail-closed: 400 ineligible_assignee). Retrying with the same request_id first reconciles any accepted or ambiguous task create through its handover-bound source identity, then returns that task or safely retries the effect; it never duplicates a task or treats a failure as final. If ownership moves while TASKS accepts a create, CRM retains a durable cleanup target and cancels only that just-created governed task on this or a later retry; existing tasks are never changed. A failed creation answers 502 task_create_failed and stays visible as next_action.error on the handover record until a retry resolves it.

POST /customer-handover/complete marks one customer's handover complete only when the gate passes, re-verified live at effect time: its persisted handover transition still matches the customer's current assigned owner and revision, the recorded next action still exists and still names this customer, its task is still open, it carries an explicit due date, and its assignee passes the live eligibility check immediately before the write. A caller-visible qualifying open task already linked to that customer may be recorded as the next action; this creates no duplicate and never changes that task's ownership or visibility. Private tasks that TASKS does not authorize the caller to read are not selected or named. Each refusal names the missing requirement (no_current_handover, handover_not_current, no_next_action, next_action_unavailable, task_customer_mismatch, task_not_open, missing_due_date, assignee_ineligible) and leaves the handover pending and the ownership untouched. A completion is immutable per handover transition: an unqualified request addresses the current transfer, while an authorised historical receipt can be replayed with its handover_transition_id and returns 200 replayed:true after a later handover. Later next-action attempts for a completed current transfer are refused with 409 already_complete. Existing tasks and independently owned records are never reassigned by any route in this section.

Customer health signals

Successful customer list and detail reads include customer_health: healthy, at_risk, or critical. Health uses last contact, open opportunities, and eligible overdue tasks. It is computed at read time and does not change profile status.

Private tasks never contribute. A team task contributes only when it is open, overdue, and the current customer owner is its assignee or creator. Owners and managers see the same health. Other people’s restricted tasks do not affect it.

If task signals cannot be verified, the read returns HTTP 503 with customer_health_unavailable. Retry later; unavailable signals are never treated as zero overdue tasks. The internal task aggregate is not a public API.

Communication review access

GET /crm/communications/queue?view=pending|discarded and GET /crm/communications/pending filter the queue before applying their limit. An item listed with no stored match proposal has one generated through the shared matcher before the list is returned — one-time per item, and a rate-limited scan leaves that item not matched instead of failing the list. Representatives may triage unlinked items and read items linked to customers they currently own. An inaccessible linked item is omitted; an empty list returns 200 {"items":[]}. view=discarded lists the organisation's discard decisions newest first, each with discarded_by, discarded_at and reason, under the same boundary; an unknown view answers 400 invalid_view.

The item detail, its match, candidate, confirmation and discard endpoints, and GET /crm/communications/quarantine apply the same current owner boundary. An inaccessible or foreign item returns the same 404 item_not_found as a missing item. Stored confirmation results are reauthorized against their original customer before replay, including after ownership changes; changing the request customer cannot expose another owner's receipt. No permission or external-send capability is added.

Confirmation is review-only: it commits the decision, customer link, one customer timeline entry with the last-contact touch, and the queue status together, after checking current ownership inside the transaction. No derived fact is ever written, and a legacy proposals field in the request body is ignored. Concurrent retries return the winning stored result only when currently authorized; they cannot replace its target. The former POST /communications/:id/extract preview has been removed; requests answer with the generic fallback, never an item-specific response. Discard commits its decision and queue status together under the same ownership check. A failed transaction leaves no new decision or partial CRM changes.

An older incomplete confirmation with an empty result can be retried only by its original actor with current access to the item and customer. Any existing customer/contact link remains authoritative; a closed item refuses further matching or linking. Other actors cannot complete this legacy decision by supplying a replacement payload.

Mailbox content is additionally bounded by the connection's named account grant, rechecked on every request before any content is read. A personal connection answers only to its owner; a shared connection answers only to callers holding a current read/review grant plus the CRM read permission — a send-only grant is not read access. Setup support never reads mailbox content, an administrator without a grant has none, a revoked grant denies the very next detail, list, match and confirm call, and a connection with no account identity answers nobody. When the authority cannot be read the boundary fails closed: lists come back filtered empty and items answer the same 404 item_not_found as an unknown id. List filters apply at SQL level before any limit, so counts, pages and match candidates never name a hidden item; a confirmed item's source copy stays private the same way. Artifact storage keys and URLs never appear in any response, and no public artifact download route exists.

GET /crm/communications/:id's item additionally carries body_text: the stored message's own parsed body, from the same bounded rfc822 parse the publish flow's attachment manifest already runs (one R2 read serves both, never a mailbox-side storage key). It is capped at 20,000 characters and is null — never a copy of summary — when the artifact is missing, unreadable, or parses to empty text; the reviewer's evidence panel falls back to the item's own stored summary, then an accurate empty state, rather than the earlier wording that claimed no text was ever stored for a message whose full rfc822 is sitting in R2. The confirmed link object on this same read now also carries engagement_id — the deal chosen when the conversation was linked, null when none was chosen — alongside its existing confirmed_by/confirmed_at provenance, so a reader can show the linked deal with the same authorization as the customer it belongs to. The field is additive; stored links have carried the value since the deal-choice column was introduced, and this read simply stopped dropping it.

Formatted email source.The same authorized GET /crm/communications/:id item may also carry nullable body_html: the first non-attachment HTML MIME body, including a multipart alternative, decoded within a 250,000-byte limit. It is null when absent, empty, unsupported, oversized or unreadable. This is untrusted HTML, never safe application markup. Browser clients must sanitize it at the display boundary with an HTML-only allowlist, then render it in a script-disabled frame with a resource-blocking document policy. Do not insert it into the application DOM. The Inbox preserves supported text and table styles, blocks automatic external resources, and opens validated links only on explicit activation without a referrer or opener. body_text, attachment authorization and bytes, source storage, triage and sending are unchanged. Collection responses do not include body_html.

Reading in the Inbox.Open a message to read its supported formatting and tables. Wide tables scroll inside the message. Choose View plain text for the existing text reader and quoted history, then View formatted email to return. Unsupported or oversized formatting uses the text fallback. Reply, forward and attachment actions stay beside the message.

Inbox triage questions (#3565)

GET /crm/communications/questions returns the organisation's live question set: {"version","questions"}. Bilingual text is always one object, {"en","zh_cn"}, never a _en/_zh field pair. Each question is {"key","kind","prompt"} plus a kind-specific field, and the server's validator rejects any OTHER key on the object — there is no active flag and no sort_order: array position is the only order a question set has. kind is boolean (no extra field; every answer, of every kind, carries a high|medium|low confidence bucket, not boolean-only), choice (options: 2–12 {"key","label"} entries, label bilingual), or scale (levels: 2–10 {"level","label"} entries, level a 1-based sequential position rather than a free value, label bilingual, low to high). Two checks — an exact existing-customer match and 14-plus days of silence — are database facts computed outside this schema and never appear as questions.

PUT /crm/communications/questions with {"expected_version","questions"} publishes a new version and answers 200 {"version"}. A save that lost the race writes nothing and is refused 409 version_conflict, the same optimistic-concurrency shape as outreach playbooks and quotation routing — the caller reloads the live set and reapplies its edit rather than merging blind. Every synced email in an organisation with inbox-intelligence enabled is classified against the current version from a single governed model call (purpose inbox_triage); saving a new version does not reclassify past mail, only what syncs after it.

POST /crm/communications/questions/preview with {"question","limit"} (limit capped at 6) runs one question — saved or still a draft, never persisted by this call — against up to that many recent emails and answers {"items":[{"comm_item_id","subject_preview","answer","error?"}]}. answer is {"value","confidence"} exactly as classification stores it, or null when that ONE item's live model call failed — a per-item failure names its cause in error rather than failing the whole preview. Nothing about a preview changes the stored question set or any stored classification.

POST /crm/communications/triage/eval with {"items":[{"id","subject","summary","participants","direction"}]} (1–10 items; direction is inbound or outbound) classifies the supplied emails against the bilingual default question set — never the organisation's saved set — with one governed model call per item (purpose inbox_triage), metered to the caller's own session, and answers 200 {"run_id","model","items":[{"id","answers","error?"}]}. It reads no stored mail and writes nothing. A credit refusal stops the batch: that item and every later one answer error: "credit_refused" without a call. Organisation admins only (403 forbidden otherwise); hidden as 404 not_found where inbox-intelligence is off; a malformed body is 400 invalid_items. It exists for the golden-set accuracy run, bun run inbox-eval --live.

Classification is read-only against CRM data: no customer, contact, engagement, task or timeline row is ever written from any point in this surface, and every question set and classification row is scoped to the organisation derived from the authenticated connection, never from a request payload. The feature is gated inbox-intelligence, default developers_only; sorting runs against the organisation's bilingual defaults the moment a mailbox syncs, before anyone opens the settings page — this page is never a setup gate.

POST /crm/communications/triage/run asks for one bounded, user-attributed classification pass over the caller's organisation (at most 10 items, ~8s budget) and answers 202 {"started"} immediately: the batch itself runs in the Worker's own lifetime, so closing or leaving the page cannot cancel it mid-claim, and fresh counts are read from the triage-summary endpoint a few seconds later. The browser calls it when the review queue opens or Refresh is pressed; it spends metered model credits under the caller's own session, walks only the caller's organisation, and is idempotent — items already classified for the active question version are never claimed again. It exists because scheduled ticks carry no user attribution and the governed gateway fails closed without one; when a system-service usage path lands on the gateway, the cron sweep takes over and this endpoint becomes a no-op accelerator.

POST /crm/communications/triage/resort is available only to a live platform-superadmin session (Forth staff, never an organisation admin or rep) — an unauthorized caller gets the same 404 not_found shape every hidden platform surface uses, so the surface's existence is never revealed. Publishing a new question-set version never reclassifies mail already scored under an older one (see the PUT /crm/communications/questions paragraph above); those rows are otherwise stranded at their old version forever, invisible to triage-summary's CURRENT-version-only counts. This is the explicit, deliberately un-automatic fix the platform side can trigger: it claims up to 25 rows per call whose question_set_version is strictly older than the organisation's current version, re-classifies each with one governed model call exactly like a fresh classification, then answers 202 {"started"} — the batch runs in the Worker's own lifetime, same as /triage/run above. A row already at the current version, or a failed/still-generating row at an old version (both already reachable by the ordinary sweep's own retry passes), is never touched, and the ordinary sweep itself never reopens history on its own. Because this spends the same per-item model credit a fresh classification does, the platform-only gate exists purely to keep that spend off any path an ordinary organisation member can reach; a large backlog needs the call repeated since each call's claim is capped and safely idempotent to retry.

POST /crm/communications/triage/rerun-open-replies has the same platform-superadmin-only gate, the same optional {"org_id"} target and the same hidden 404 not_found shape as /triage/resort. It re-classifies only the latest inbound email of each OPEN needs-reply thread (the threads the Workbench shows as reply rows) whose stored classification was read from an older triage input, so those emails are re-read with their body text and gain the customer's ask-by date — never the rest of the mail history. An email stored before bodies were kept has its stored message read once here and its body excerpt saved; the ordinary sweep never reads stored messages. Optional "item_ids", 1–25 comm item ids (otherwise 400 invalid_item_ids): re-read exactly those emails' current classifications instead of the open threads — for example the Workbench's reply-row trigger ids — matched only inside the target organisation, so another organisation's id finds nothing. A hand-sorted email (a person's sort) is re-read for its ask-by date only: its answers, status and sort are never changed. Optional "cap", a whole number 1–25 (default 25, otherwise 400 invalid_cap); each email is one governed model call (purpose inbox_triage) metered to the caller's session. It answers synchronously 200 {"candidates","attempted","updated","skipped","creditRefused","remaining"}; repeat the call until remaining is 0. Each re-read email is stamped with the current input version, so repeating it is idempotent. A re-run that fails, or that a person's hand sort overtook, leaves the stored classification exactly as it was, so no thread leaves Need reply because a re-run failed; a model-sorted email whose re-read comes back unsure keeps its stored classification.

Inbox triage summary (#3566)

GET /crm/communications/triage-summary returns the counts, per-question distributions and ranked attention list behind the /communications overview and Today's inbox_triage decision rows: {"connected","last_sync_at","question_set_version","counts","by_question","attention"}. connected is false only when the caller has no readable mailbox connection at all (#2830 named-grant set), in which case every other field answers its empty shape rather than being queried. last_sync_at is the latest connected connection's sync time in epoch ms, or null before a first sync. counts is {"found","ready","unsorted","generating","failed"} over comm_items in the caller's readable, in-scope connections. A displayed "not sorted yet" figure is NOT one of these five counts — it is derived client-side as found - ready - failed, folding the low-confidence unsorted bucket, in-flight generating rows and any item with no classification row at all under the CURRENT active question-set version into one neutral number, never amber or red: honest incompleteness is not a warning. by_question is keyed by the org's active question keys, zero-initialised for every active question then folded over ready answers only: {"kind":"boolean","true","false"}, {"kind":"choice","options"} (an object keyed by option key), or {"kind":"scale","levels"} (an object keyed by the level number as a string).

attention is the deterministic, capped, ranked list services/crm/src/services/inbox-attention.ts computes fresh on every read — nothing about it is stored. Each item is {"comm_item_id","source":"inbox_triage","occurred_at","expires_at":null,"decided_at":null,"subject_preview","score","factors","customer"}. subject_preview is the raw subject sliced to 120 characters, the same bound the queue's own preview route uses. factors is a list of {"key","weight","evidence"} — integer weights, structured evidence, no model call anywhere in the ranking: needs_reply_urgent (40, the needs_reply and urgency answers both fire), purchase_order (30), quotation (20, the quotation_discussed answer), and silence_14d (10, a matched customer with no recorded activity in 14+ days, or ever). score is the sum of its factors' weights; only items with at least one fired factor appear, ordered score desc, then occurred_at desc, then comm_item_id asc, capped at 10 per read. Only ready classifications ever contribute a factor — an unsorted low-confidence answer never renders as a settled attention reason, one layer up from where classification itself already refused to guess. customer is {"id","name"} when the item has a confirmed customer link, or null for an unmatched item (still org-visible, since there is no "the customer" to have gone quiet on). The same rows, unchanged, are what Today's inbox_triage group renders: expires_at and decided_at are always null in this slice (S3 owns the CRM-write review flow that would decide one), so the list's own 10-item cap is what bounds queue crowding rather than expiry.

The route applies the same #2830 named-grant content boundary and #1509 owner scope as every other communications read on this surface: an item confirmed-linked to a customer outside the caller's scope is excluded entirely from counts, distributions and attention alike, never merely un-named. Classification is read against the org's CURRENT active question-set version only, exactly like the queue's own classification field. The route is gated inbox-intelligence; a 404 means the org's flag is off, and both the overview and Today's feed treat that 404 as "flag off", never a broken feed — no client-side flag read decides what renders.

#3595 S4 adds an additive trend field to this same read: {"weeks":[{"weeks_ago","window_start","window_end","classified","by_question"}]}, ordered OLDEST first (the last array entry is weeks_ago:0, the current rolling 7-day window ending now — "weekly" here means rolling 7-day windows, not calendar weeks, so no timezone choice is baked into the boundary). classified is the count of ready classifications that settled in that window, shared across every question (one governed call answers the whole active question set at once). by_question is keyed by the org's CURRENT active question keys only — a correction recorded against a since-removed or renamed key never appears — each entry {"corrections","rate"} where rate is corrections / classified for that week, or null when classified is 0 ("no data", never rendered as a flat 0%). This is a CORRECTION-RATE trend, not a measured-accuracy claim: nobody has graded these answers against ground truth, and a falling rate is the accuracy PROXY this slice ships. Computed organisation-wide — never filtered by the caller's own #2830 readable-connection set or #1509 owner scope, matching the org-wide pool inbox-corrections-retrieval.ts's own few-shot retrieval draws from — but always isolated to this ONE organisation's own rows.

Communications per-mailbox filter (#3733)

GET /crm/communications/queue?view=pending|discarded, GET /crm/communications/quarantine and GET /crm/communications/triage-summary each take an optional connection_id query parameter that narrows their result to ONE connected mailbox — the /communications page's per-mailbox filter. Omitting it keeps today's unfiltered behaviour: every mailbox the caller may read, exactly as before this slice.

The parameter is validated against the caller's OWN already-resolved #2830 named-grant readable-connection set, never against the database directly and never against another organisation's rows: connection_id is narrowed to that one id only when it already appears in the caller's readable set, exactly the same set every one of these reads already scopes by. A foreign organisation's connection id, an id this member's own grants do not cover, or an id that names nothing at all all answer the identical 200 {"items":[]} (or, on triage-summary, the same empty-shape response the caller already gets with zero readable connections) — the same posture the existing "no readable connection" case already uses. It never falls back to the unfiltered set on an invalid id, and it never distinguishes "not yours" from "doesn't exist". On triage-summary only the counts/distributions/attention narrow to the filtered mailbox; the additive trend field stays organisation-wide and is gated on whether the caller has ANY readable connection at all, not on the filter, matching its own documented org-wide posture above.

GET /crm/communications/mailboxes lists the filter's own option set: {"items":[{"id","channel","label","external_account_id"}]}, ordered as stored, over exactly the caller's #2830 readable-connection set. It is deliberately narrower than GET /crm/communications/connections (which requires the admin-gated inspect_setup action under the legacy-shared policy): a representative with only a content-read grant on a shared mailbox, and no integrations management access, still appears here so they can filter by their own readable mailbox even though that same connection would not appear on the Settings → Integrations management list. An empty readable set answers 200 {"items":[]}.

Inbox reading surface (#4269)

GET /crm/inbox/accounts lists the linked accounts the caller may read mail for: {"items":[{"id","channel","label","external_account_id","status","pending_count","unread","spam","sync"}]}, ordered as stored. The rows are the caller’s OWN #2830 named-grant readable-connection set — the same set the queue, quarantine and mailboxes reads already scope by, deliberately narrower than the admin-gated GET /crm/communications/connections list — and unread is the sidebar badge: one grouped count, bounded to that set at SQL level, of received mail from people (direction='inbound', normal mail, not automated or failed-check) with status pending or confirmed that the caller has not opened, with the same owner visibility as the items view; it equals counts.unread of GET /crm/inbox/items?view=emails&unread=true&connection_id= for that account, and another person’s read never lowers it. pending_count (items awaiting review) is kept for compatibility. Additively, each row carries sync — {"state":"never|running|idle|failed","last_success_at"} — the same per-account sync state the connection list publishes, so a mailbox whose first fetch is still under way can say so instead of reading empty; the field is absent on an older CRM mid-deploy and reads as unknown. Additively, spam counts received automated mail per account (direction='inbound', mail_kind='automated', status pending or confirmed, read or not, not moved out by the caller’s personal state), within the same readable set and owner visibility; it equals hidden_counts.automated of GET /crm/inbox/items?view=emails&connection_id= and lets a new mailbox whose only mail is automated show that mail arrived. An older CRM omits it; read it as 0. An unreadable account’s rows never join a badge. A caller with no readable connection gets 200 {"items":[]}, never a 403.

GET /crm/inbox/items?connection_id=&limit=&cursor= returns one page of message snapshots, newest first: {"items":[…],"next_cursor"} where each row is the same publicItem shape the queue serves (participants and metadata parsed back from JSON; no artifact storage key ever rides a row). Statuses are pending and confirmed only — a discarded item is a recorded decision, not mail to read — ordered occurred_at DESC, id DESC. limit clamps to 1–200 (default 50). next_cursor is present only when a full page returned and is opaque; a malformed cursor parses to nothing and is treated as absent (the first page), never an error. connection_id is the same per-mailbox filter the queue reads apply, validated against the caller’s OWN readable set: another organisation’s connection id, an id this member’s grants do not cover, and an id that names nothing at all answer the identical 200 {"items":[]}, never the unfiltered set and never a 403 that would distinguish “not yours” from “doesn’t exist”.

Both reads are gated on the inbox registry key (default developers_only): while the flag is hidden, both answer 404 not_found to every caller — a platform superadmin included; hidden means hidden, never a 403 that would confirm the surface exists. Every query is organisation-scoped from the verified session. The reading pane’s per-message content is the EXISTING GET /crm/communications/:id (with its body_text and attachment manifest) under its own documented named-grant boundary; this surface adds no write, send or compose capability anywhere.

Personal Atlas email state

Star, read, archive and recoverable Trash affect only the signed-in person's Atlas view. They never change provider labels, delete the stored email, change a colleague's view or turn a CRM discard decision into mailbox deletion. Actions apply to the displayed email ID, including a conversation row's latest eligible email; they do not move the whole conversation or future arrivals. Junk remains Atlas's existing automated-mail classification.

GET /crm/inbox/items/:id/personal-state returns {"item_id","state":{"starred","placement","revision","unread"}}. Missing personal state means unstarred, Inbox and revision zero; unread comes from the existing comm_item_reads markers. Source authorization happens before the revision is revealed. The signed-in organization and person, live connected readable mailbox grant and customer owner scope bound both reads and writes. Unknown, foreign, discarded, non-email or unreadable items answer 404 item_not_found; a hidden Inbox feature answers 404 not_found.

PATCH /crm/inbox/items/:id/personal-state accepts exactly {"action":"star","expected_revision":0} (illustrative). Actions are star, unstar, read, unread, archive, inbox, trash and recover. A malformed body or outbound unread request answers 400 invalid_action. A successful write returns the saved state and incremented revision. A stale revision answers 409 state_changed with no state or marker effect: read the current state and explicitly decide again; never retry with a guessed revision. Trash remembers Inbox or Archive before removal; repeat Trash preserves that destination, and Recover restores it. There is no permanent-delete action.

GET /crm/inbox/items?view=emails&folder=starred, folder=archive and folder=trash list personal folders including received/sent and Junk emails. Starred includes Inbox and Archive, excluding Trash. Omitted folder means Inbox; all counts/cursors use the same selected placement. Explicit direction or kind cannot be combined with these local folders (400 invalid_filter). Conversation answer evidence still includes archived/trashed outbound replies. POST /crm/inbox/items/:id/read remains compatible for untouched mail and repeats an existing true read; after explicit personal state with no read marker, it returns 409 state_changed rather than reversing an explicit unread. New Inbox clients use the revision-aware PATCH for automatic reads. Account disconnect and provider mutations are outside this interface.

Mark an email as Junk in Atlas

“Mark as Junk · Atlas only” changes the shared Atlas classification for this mailbox's authorized readers. It never moves provider folders, deletes an artifact or changes personal read/star/archive state. The action applies to the displayed received email; a mixed conversation may remain in Inbox when another email or sent reply is normal. Provider sync behavior is unchanged.

POST /crm/inbox/items/:id/junk accepts no body or exact {}, returning 200 {item_id,moved:0|1}. A repeat already-Junk mark returns moved:0. Unknown, foreign, disconnected, unreadable or owner-invisible mail returns 404 item_not_found; hidden Inbox feature returns 404 not_found. An authorized outbound, non-email or discarded source returns 400 not_markable_junk; invalid JSON or unknown body fields return 400 invalid_action. Current organization/person, connected readable account and customer owner scope are checked after any client-controlled body is read.

Authorized failed-sender inbound mail can be categorized as Junk. When its existing failure was carried only by mail_kind=failed_check, changing the category to automated retains equivalent evidence as sender_check=failed. Existing failure flags, verification evidence, review status and ready classification answers remain. Both Junk and the inverse keep sender_check_failed no-draft/send restrictions and prevent automatic CRM logging.

POST /crm/inbox/items/:id/not-junk remains the explicit inverse: authorized automated inbound emails in pending or confirmed status in the same organization, account and thread become person mail; outbound, non-email and discarded rows remain unchanged; ready answers stay ready, and unsorted received mail waits for manual sorting. The question-set read finishes before current connected mailbox and owner admission. Every affected email is authorized first; one hidden target refuses the entire batch with 404 item_not_found and no effects. Newly arriving messages outside the authorized target snapshot are not added. Existing 400 not_junk for an already normal source remains. UI controls disable immediately while pending, restore on refusal and reject old actor/account/email completions. A saved action with failed refresh is reported as saved but stale; reload to see the authoritative result. No automatic inverse or provider operation is issued.

Ask whether an email is genuine

Atlas does not draft a reply to an email whose sender check failed, whose Reply-To differs from its sender, or that sorting flagged suspicious. Instead of a Workbench reply task (which then read as Overdue), the Inbox asks the person reading it whether the email is genuine. Until someone answers, no reply task is raised for it, and an open one steps aside (closed as superseded). “No, it’s spam” is the existing POST /crm/inbox/items/:id/junk.

Every GET /crm/inbox/items read (view= reads and thread_key reads) adds genuine_check to a received email that needs this question: {"reason":"sender_check_failed|reply_to_mismatch|suspicious|payment_change","confirmed":false|true}. Other rows omit it.

POST /crm/inbox/items/:id/genuine records “Yes, it’s genuine” for the organisation (a fact about the email, not one person’s view) and returns 200 {item_id,genuine_check} with confirmed:true; a repeat keeps the first confirmation. DELETE on the same path is its Undo and returns confirmed:false. Once confirmed, the next task sweep (every five minutes) raises the reply task and Atlas drafts it as usual, except an email about changed payment details (payment_change), which it never drafts. A received email with nothing to check, or a sent email, answers 409 not_applicable. Unknown, foreign, disconnected, unreadable or owner-invisible mail answers 404 item_not_found; a hidden Inbox feature answers 404 not_found. The organisation, person, connected readable mailbox and customer owner scope are checked exactly as for Junk.

Inbox Intelligence CRM-entry proposals (#3567)

POST /crm/communications/:id/proposal/build builds — or, idempotently, RETURNS WITHOUT RE-EXTRACTING — the one proposal for a communication item, and answers {"proposal"}. A 404 item_not_found covers an unknown, cross-tenant, or content-boundary-refused item; the route is gated inbox-intelligence, 404 when the org's flag is off. When nothing applies the answer is {"proposal":null,"reason"} — NEVER an HTTP error — with reason one of classification_not_ready (the item's classification under the org's current question-set version is not yet ready), no_signal (an unmatched item that did not classify as a new business inquiry, or a matched item whose classification fired none of purchase_order/quotation_discussed/samples_discussed), or duplicate_open_engagement_no_stage_match (a matched customer already has an open engagement and the organisation's live pipeline catalogue names no stage matching the fired signal — proposing a second, generic opportunity here would silently risk a duplicate deal, so nothing is proposed instead). An UNMATCHED item classified as a new inquiry builds a kind:"lead" proposal: subject/participants/summary become synthetic cited evidence blocks fed through the SAME governed extraction pipeline `customer-staging` already uses (EXTRACT → NORMALISE → MATCH), so the payload's proposal field is exactly one `StagedCandidateProposal` (see the staged-customer-candidate sections above) — every populated value carries a `high|medium|low` confidence grade and source evidence, or is {"state":"conflict","alternatives"}, or is {"state":"blank"}; nothing is ever guessed. The full lead payload is {"proposal","matches","suggested_lead_source":"website","entry_stage","opportunity_title","source":{"comm_item_id","subject","occurred_at"}}. A MATCHED customer whose classification fires purchase_order, quotation_discussed or samples_discussed instead builds a kind:"opportunity" (no open engagement yet) or kind:"stage" (moving the existing open engagement) proposal, payload {"customer_id","customer_name","engagement_id","from_stage","to_stage","signal","rationale","evidence":{"answer_keys"}} — from_stage/to_stage are STAGE KEYS from the organisation's own live pipeline catalogue (`GET /crm/settings/pipeline`), never a hardcoded list; rationale is one of matched_catalogue_key, highest_probability_open_stage, or no_catalogue_stage_match_used_entry_stage. A built row is unique per (org, comm_item, kind) — reopening the same item's review never re-runs a governed model call while the proposal is pending or confirmed, and an unmatched-lead build is the only kind that ever spends one. A DISCARDED proposal is the exception: rebuilding it REOPENS the same row — extraction re-runs (one deliberate, user-attributed call for a lead), the fresh payload replaces the old one, the status returns to pending, and the earlier discard stays on the correction audit trail. Discard is a decision, not a tombstone.

GET /crm/communications/proposals?status=pending|confirmed|discarded (default pending; any other value is 400 invalid_status) answers {"items":[proposal]}, org- and rep-owner-scoped exactly like the review queue itself, newest first. Each proposal row is {"id","org_id","comm_item_id","kind","payload","staging_id","status","discard_reason","confirmed_by","confirmed_at","created_at","updated_at"} — staging_id is set only for a lead proposal, referencing the immutable `customer_staging_candidates` audit row the same extraction wrote.

Two optional filters narrow the list to one email and its conversation: comm_item_id (the email) and thread_key (its conversation). Either given matches either; with neither the list is unfiltered as before. The organisation, readable-mailbox and owner scope are applied exactly as without them, so a filter can only ever return fewer rows: an email in a mailbox the caller cannot read, or another organisation's id, answers 200 {"items":[]}. The Workbench reply task reads its deal suggestion this way: GET /crm/communications/proposals?status=pending&comm_item_id=&thread_key=.

Deal suggestions are built automatically. When the organisation's opportunity.update_from_inquiry permission is with_permission (the default), the scheduled inbox pass builds a pending kind:"stage" proposal for each inbound email from the last 14 days that is sorted ready with purchase_order, quotation_discussed or samples_discussed, has a confirmed customer link, and whose customer has an open deal the planner would move forward. It never confirms one: the deal changes only when a person confirms through POST /crm/communications/proposals/:id/confirm. Each email is suggested at most once, a discarded suggestion is never rebuilt by the scheduled pass, no suggestion moves a deal backwards or to a stage it is already at, and an email with no open deal gets none (the pass never proposes a new deal). At by_itself the same pass applies the proposal as before; at never it builds nothing.

POST /crm/communications/proposals/:id/confirm with an optional {"corrections":[{"field","prior","corrected"}],"accepted_fields":["company.name","contacts[0].email",…]} records every `corrections` entry as a `comm_triage_corrections` audit row for the AC8/S4 few-shot loop BEFORE the confirm effect runs — a rep's recorded decision is a fact regardless of whether the confirm itself then succeeds — AND now applies those corrections to the actual write: a rejected field is excluded, an edited field ships the edited text, and a resolved conflict ships the picked alternative. accepted_fields names every OPEN field path (low/medium-confidence proposed, or a conflict resolved to one of its own alternatives — never a high-confidence pre-accepted field, which needs no entry because the server recomputes that default itself) the rep EXPLICITLY accepted WITHOUT editing it. It exists because `corrections` only carries an entry when a field's prior and corrected text differ: accepting an open field exactly as proposed leaves prior === corrected, which is otherwise indistinguishable on the wire from a field the rep never touched at all — and the two resolve oppositely (write the accepted value vs. exclude it by the same safety rule that leaves an untouched open field blank). `company.lead_source` is an ordinary reviewable field under this same rule, falling back to the proposal's own suggested_lead_source only when the confirm request names no value for it at all. Confirm is exactly-once via a stored receipt (the same `0035` pattern this surface's other confirmations use): the first call CREATES — a customer (entry class lead), its primary contact, and an entry-stage opportunity for kind:"lead"; a new engagement at to_stage for kind:"opportunity"; the stage move (history, timeline, change event, won-promotion when applicable) for kind:"stage" — and answers 201 {"proposal_id","created":true,"kind","result"}; every replay (including a retry after a `409 confirm_conflict`) answers 200 with created:false and the ORIGINAL stored result, never re-executing the write or re-reading a retried request's `corrections`/`accepted_fields`. Refusals: 404 not_found (unknown/cross-tenant id); 409 already_discarded (someone already discarded it — nothing left to confirm); 409 confirm_conflict (a concurrent confirm attempt is mid-flight, or the row is in an inconsistent confirmed-with-no-receipt state) — safe to retry the SAME confirm call, which either replays the true stored outcome or fails again, and a retry SHOULD omit `corrections` the first attempt already recorded (a plain audit-row insert with no replay guard) while `accepted_fields` is harmless to repeat (the write itself is gated by the stored receipt, not by that field).

POST /crm/communications/proposals/:id/discard with {"reason"} (1–500 characters; empty, missing or over-length is 400 reason_required) changes NO record — the same zero-write discard posture as every other decision on this surface — and answers 201 {"proposal_id","created":true,"status":"discarded","discard_reason"} first time, 200 with created:false and the ORIGINAL reason on replay. 404 not_found covers an unknown/cross-tenant id; 409 already_confirmed means the proposal was already confirmed (by this rep or another) — its discard request is moot, not an error to relitigate.

All four routes are gated inbox-intelligence (404 when off) and apply the SAME org- and owner-scope boundary as the rest of this surface: a `lead` proposal is visible to anyone who may read its source item; an `opportunity`/`stage` proposal additionally requires the caller's owner scope to include the matched customer.

GET /crm/communications/won-verifications?thread_key= (or ?comm_item_id=; exactly one is required, otherwise 400 invalid_request) lists the Won verifications opened from a purchase-order email, newest first, as {"items":[{"id","engagement_id","deal_title","customer_id","comm_item_id","status","filename","created_at"}]}. status is pending, confirmed, confirmed_partial, declined or superseded; filename is the emailed PO file, or null. Empty, or not visible to the caller, answers 200 {"items":[]}. Like the proposals list it answers 404 not_found when inbox intelligence is off for the organisation. A purchase-order email on a customer's only open deal (or the deal named on its link) files the PO attachment on that deal and opens the same pending check an uploaded PO opens; the file is not read automatically. The Workbench reply task reads it to point the rep at the deal's "Won · verify" check. A purchase-order email no longer produces a stage proposal; the decision to mark the deal Won is only ever made by a person on the deal page.

Internal, service-binding only — not reachable by any browser caller: GET /internal/pipeline-stages?org= (HMAC-signed internal-principal request, 403 forbidden otherwise) returns the requesting organisation's ACTIVE pipeline catalogue as {"items":[{"key","name","type","sort_order"}]}, uncached, so an admin's same-session stage edit reaches the very next call. The agent Worker's `draft_stage`/`draft_opportunity` tools read this instead of a hardcoded stage list — the same live-catalogue rule this proposal builder itself follows.

Inbox items and conversations

GET /crm/inbox/items returns the caller's readable mailbox messages, newest first, as {"items":[...],"next_cursor"}. It is gated by the inbox feature flag: a 404 means the organisation's flag is off. Only messages in mailboxes the caller can read are returned (the same named-grant boundary as the other communications reads); discarded messages never appear. An empty result, an unreadable or unknown connection_id, or an unauthorised caller all answer 200 {"items":[]}.

Paging: limit (default 50, maximum 200) and cursor (pass the previous response's next_cursor; a malformed cursor is read as the first page). connection_id narrows to one readable mailbox. Optional filters, all combined with AND: group=thread returns one row per conversation (its latest readable message) and adds thread_count, the number of readable messages in that conversation; thread_key=<key> returns one conversation oldest first (it cannot be combined with group); email_type (a sorting-label value such as quotation); needs_reply=true (still accepted; the Inbox screen no longer offers it); urgency_min (an integer 1 to 5); customer_id (messages linked to that customer). In a flat list each message must itself match every filter. With group=thread a conversation matches email_type if any message in it has that type and customer_id if any message in it is linked to that customer, while needs_reply and urgency_min are judged on the conversation's latest sorted inbound message. An invalid value for any of these is refused with 400 {"error":"invalid_filter"}; a filter is never silently dropped.

Each row carries the usual message fields plus thread_key (the conversation it belongs to), thread_basis and classification. thread_basis is headers when the conversation came from the email's reply headers, or single when the message stands alone. A conversation is exactly one email thread: emails are never joined by subject (rows grouped by subject before 2026-10-11 are re-threaded from their own headers; inferred may still appear on a row until that has run). classification is the message's sorting answers under the organisation's current question set, or null when it has none. Rows from GET /crm/communications/pending also carry thread_key and thread_basis.

Conversations and Emails view. Add view=conversations or view=emails to read the Inbox as the screen does; without view the read above is unchanged. The response becomes {"items":[...],"next_cursor","counts","hidden_counts"}. kind=person|automated|failed_check chooses which messages are listed (default person). needs_reply=true and urgent=true (urgency 4 or 5) narrow both the rows and every count. view=conversations also takes latest_topic=inquiry|quotation|sample|purchase_order|invoice_payment|logistics|after_sales|supplier|promotions|job_application|notice|other|not_sorted; view=emails also takes covers= a comma-separated list of the first seven topics, which must ALL be covered (AND). view=emails lists received emails only: a sent email is never a row or a count there, and appears in its thread (view=conversations). Combining a parameter with the wrong view, an unknown value, or the legacy group, email_type, urgency_min or customer_id filters is refused with 400 {"error":"invalid_filter"}; thread_key keeps returning the unchanged thread read. A caller with no readable mailbox, or an unreadable connection_id, gets 200 with empty items and every count zero.

Conversation facts are taken from the latest sorted (status ready) inbound message of the conversation: latest_topic is its email_type (any value outside the first six reads other; no sorted inbound message reads not_sorted), and needs_reply and urgent are its answers. covers is the union over all sorted inbound messages of their email_type and the flags for inquiry, quotation, samples, purchase order and invoice or payment. In the Emails view each message stands alone: its own type and flags, and its own needs_reply and urgent; a sent message has none.

counts.all is the number of conversations (or emails) of the chosen kind that match the narrowing above. In the conversations view counts.latest_topic always adds up to counts.all, and choosing a latest_topic changes the rows but never a count. In the emails view counts.covers.T is the number of emails that would match if topic T were ticked as well, so the covers counts overlap and do not add up. hidden_counts is the number of automated and failed_check items under the same narrowing (including the ticked covers), so shown plus hidden equals everything that matches. A conversation is person if any message in it is, so a real customer's thread is never hidden by one automated message in it.

Each row adds thread_key, covers (array), needs_reply, urgent, mail_kind, sender_check and not_sorted_reason; conversation rows add thread_count and latest_topic. mail_kind is decided when the message arrives from its headers: a known sender (a contact of the organisation, or a conversation we already wrote in) is always person, with sender_check set to failed if the mail provider's checks failed; otherwise a failed DMARC check, a failed Microsoft 365 composite check (compauth=fail), or a failed or soft-failed SPF check without a DKIM pass aligned to the From domain is failed_check; only the topmost Authentication-Results header is read, because the receiving provider adds its own on top and anything below may be supplied by the sender; otherwise bulk, list, auto-submitted, delivery-report or no-reply mail is automated; everything else, and any message not yet classified, is person. not_sorted_reason is null when sorted, otherwise one of queued, older_than_30_days, low_confidence (with candidates when two are stored), retrying (with attempt and max_attempts), failed, credit_paused, sorting_off, or only_sent (a conversation with no inbound message; view=conversations does not list these threads or count them, because they belong in Sent until a reply arrives).

Linking a conversation to a customer and deal. Every person row in both views carries link: {"state","customer_id","customer_name","deal_id","deal_title","how","new_contact","deals","reason"}, judged per conversation (a message without a thread is its own conversation). state is suggested, confirmed, not_customer or none. A suggestion is computed when the list is read, from the organisation's saved contacts only, with no model call, and never writes anything. Strongest first: how=contact when an inbound sender address equals a contact email of exactly one customer the caller can see; otherwise how=domain when the sender's domain is the email domain of the contacts of exactly one such customer (shared free-mail domains such as gmail.com or qq.com never count), with new_contact set to that sender address; otherwise state=none. Two customers sharing an address or a domain suggest nothing. If the customer has exactly one open deal (not won or lost) it is suggested as deal_id and deal_title; otherwise deals lists the open deals (id, title, stage) to pick from. A conversation holding any message that failed its sender check (mail_kind=failed_check or sender_check=failed), or any message whose Reply-To names a different domain than its From, is never suggested a customer: it reads state=none with reason=sender_unverified. A rep only ever sees suggestions about customers they own; a manager or admin sees all of the organisation's. confirmed means every readable message of the conversation is linked to the same customer, and then customer_id, customer_name and any chosen deal are shown if the caller can see that customer. needs_linking=true (only with view) keeps person conversations whose state is suggested or none (automated and failed-check conversations never need linking, so hidden_counts is 0 under it), and narrows the rows, counts and hidden_counts like needs_reply. counts.exact_matches is the number of conversations that POST /crm/inbox/links/confirm-exact would link right now (see below); it ignores the topic and reply filters, honours connection_id, and looks at the newest 500 unlinked conversations. It is computed on the first page only: a read with a cursor omits the field, so keep the value from the first page. A conversation that already has any link, or a not-a-customer decision, is never counted.

These four writes need a person's explicit action. Apart from them, Atlas links a conversation by itself only in the two cases described in the next paragraph (a reply on a conversation a person already linked, and mail from a saved contact). All answer 404 {"error":"item_not_found"} for an unknown item, another organisation's item, or an item in a mailbox the caller may not read (the same named-grant and owner-scope rules as POST /crm/communications/:id/confirm), and 404 not_found when the inbox flag is off. The organisation is always the session's. POST /crm/inbox/links/confirm with {"item_id","customer_id","deal_id"?,"add_contact"?} links every readable message of that item's conversation to the customer, sets the chosen deal (null or absent means no deal) and answers 200 {"confirmed":n,"contact_added":bool}. The customer must exist in the organisation and be visible to the caller (otherwise 404 customer_not_found; 400 customer_id_required when missing) and the deal must belong to that customer (otherwise 400 deal_customer_mismatch). Links the review confirm made are never replaced; links to another customer made from the Inbox are replaced for this conversation only. add_contact=true also saves the conversation's sender (the newest received sender, with the display name from the From header) as a contact of the customer, whether or not the customer has contacts or a matching domain, but not when the conversation has an unverified, failed-check, automated or Reply-To-mismatched message, and not when that email is already a contact of the customer (contact_added:false); the address is recomputed on the server and never read from the request. POST /crm/contacts with an email already on that customer (trimmed, case-insensitive) answers 200 {item, existing:true} with the existing contact instead of creating a second one. POST /crm/inbox/links/confirm-exact with {"connection_id"?} confirms every conversation whose suggestion is how=contact and whose customer has at most one open deal (that deal, or none) and, to be included, every inbound message of the conversation must have a proven sender (the topmost Authentication-Results shows dmarc=pass, or a DKIM or SPF pass aligned to the From domain; more than one such header proves nothing) or carry no Authentication-Results header at all (a provider that does not stamp mail). A suggestion whose sender is not proven is still shown, with unsure:true, and a person can confirm it by hand, but it is never in confirm-exact and add_contact saves nothing for it. It answers 200 {"confirmed":n,"remaining":m} counting conversations. It links at most 50 conversations per call; remaining is how many exact matches are still pending afterwards, so call again until it is 0. A conversation that already holds any link (for example a new reply arriving in a confirmed conversation) is never included. POST /crm/inbox/links/not-customer with {"item_id"} records that the conversation is not a customer (200 {"marked":true}; 409 already_linked while it still carries a link). POST /crm/inbox/links/clear with {"item_id"} removes that decision and the conversation's Inbox links (200 {"cleared":n}), never a link made by the review confirm; Atlas never links a cleared conversation again by itself, only a person can.

Automatic links from saved contacts. A conversation is linked to a customer without anyone confirming it when its sender's exact address is a saved contact of exactly one customer of the organisation. A link is made only when the customer's owner can read the mailbox holding the mail: their own personal mailbox, or a shared mailbox where they hold a read grant (so a personal mailbox only ever reaches its owner's own customers, whatever the owner's role); otherwise the conversation stays a suggestion. Atlas does this for new mail as it arrives (only while the organisation's inbox flag is on; a long conversation stays a suggestion) and, for mail already in the Inbox, right after POST /crm/inbox/links/confirm or POST /crm/contacts with an email: that customer's other conversations from its saved contacts are linked, in the mailboxes the caller can read and among the customers the caller can see. The responses are unchanged; the linking happens just after them. A conversation a person links, clears or marks not a customer while this runs keeps that decision. Such a link carries confirmed_by auto:contact and reads as state: "confirmed" like any other; the customer's single open deal is set when it has exactly one. A conversation is never linked automatically when: it already holds any link, is marked not a customer, or was cleared; any of its messages failed the sender check, is failed-check, or has a Reply-To on another domain; any received message's sender is neither proven nor unstamped (the same proof rule as confirm-exact: a verdict that proves nothing blocks it, a provider that adds no Authentication-Results does not); the address is a contact of two or more customers (or its senders are contacts of different customers); or only the email domain matches. Those conversations keep their suggestion for a person to confirm.

Sort now. POST /crm/communications/triage/run accepts an optional body {"item_ids":["comm_..."]} of 1 to 25 ids (otherwise 400 invalid_item_ids). With ids, only those messages are classified, and only the ones in a mailbox the caller can read: an unclassified message (including one older than 30 days) or a failed one with attempts left (an explicit Sort now allows three more attempts than the automatic retries) is claimed; every other id is ignored without any difference in the answer, which is always 202 {"started":true}. Automated and failed-sender-check messages are never sent to the model, with or without ids. Without a body the call is unchanged.

Sorting status and personal topics. view=emails also takes not_sorted_reason= a comma-separated list of low_confidence, retrying, failed, credit_paused, queued, older_than_30_days, sorting_off; it keeps only inbound emails not yet sorted for a listed reason and combines with the other filters (any other view, or an unknown value, is 400 {"error":"invalid_filter"}). The first page (no cursor) also returns sorting: {"total","sorted","waiting":{"low_confidence","retrying","failed","credit_paused","queued","older_than_30_days","sorting_off"}}, independent of every filter except connection_id. It counts inbound person mail only (sent, Automated and Failed-sender-check mail are never sorted, so never counted), and sorted plus every waiting count equals total. Every read also returns personal_topics: [{"id":"p:<id>","label","label_zh"}], the caller's own topics, oldest first, [] when none. latest_topic and covers accept a p:<id> key of one of the caller's own topics, and counts.latest_topic and counts.covers gain one key per personal topic; for its owner an email filed under a personal topic counts under that key instead of other, so the counts still add up to counts.all. Every row, and every item of the thread_key read, carries personal_topic ("p:<id>" or null). classification.model is person when a person sorted the email by hand.

Sort one email by hand. POST /crm/inbox/items/:id/sort with {"topic" | "new_topic":{"label","label_zh"?},"covers"?:[...],"needs_reply":bool,"urgent":bool}. Send exactly one of topic (a built-in key inquiry, quotation, sample, purchase_order, invoice_payment, logistics, after_sales, supplier, promotions, job_application, notice, other, or the caller's own p:<id>) and new_topic (label 1 to 60 characters after trimming, label_zh optional, at most 60). covers takes built-in keys except other and defaults to []. A new_topic whose label equals, ignoring case, a built-in topic's English or Chinese name files under that built-in; one equal to the caller's own topic reuses it, with no duplicate. The answer is written as the email's shared sort, so colleagues see the topic, covers, needs-reply and urgent flags; a personal topic is shared as other and only its creator sees the topic itself, so colleagues see the email as Other. The answer is also recorded as a correction that Atlas learns from as an example for similar emails. Conversation grouping does not change. 200 {"item_id","topic","personal_topic":{"id","label","label_zh","created":true}|null,"sort_id"}. The classification, your filing, correction examples, any new topic and the Undo snapshot commit together. If that state changes before commit, 409 sort_changed writes nothing; reload before sorting again. Errors: 400 invalid_request (malformed body, or both or neither of topic and new_topic), 400 topic_name_required (empty or over-long new_topic.label), 400 unknown_topic (not a built-in key or one of the caller's topics), 400 not_sortable (sent mail, or Automated or Failed-sender-check mail), 404 item_not_found (unknown, another organisation's, or in a mailbox the caller may not read), and 404 not_found when the inbox flag is off.

Undo a sort. POST /crm/inbox/items/:id/sort/undo with {"sort_id"}, the sort_id that sort answered. Same inbox flag and item rules as the sort route. Only the person who made the sort can undo it, and only while it is still the email's latest sort. The email gets back exactly what it had before: its earlier sort, or none (it waits to be sorted again, as before), and the caller's own topic filing. The corrections that sort recorded are removed, so Atlas no longer learns from them, and a personal topic that sort created is removed when no other email is filed under it. 200 {"item_id","undone":true}; repeating it, or a sort_id that is unknown, another person's, or replaced by a newer sort, answers 200 {"item_id","undone":false} and changes nothing. Errors: 400 sort_id_required, 409 sort_changed (the email's sort or filing changed, or its saved Undo state is incomplete; nothing is written), 404 item_not_found, and 404 not_found when the inbox flag is off.

Atlas's best guess for an unsorted email. GET /crm/inbox/items/:id/sort-assist and POST /crm/inbox/items/:id/sort-assist use the same inbox flag and item rules as the sort route above. GET never calls AI and costs nothing: it returns the stored answer, or assist_status: "none" when nothing is stored yet. POST runs one governed AI call, billed to the organisation under the caller's own session, only when no stored answer matches the related emails the caller can read; otherwise it returns the stored one. A stored answer is reused until a new email arrives in the same conversation or from the same sender. Neither route sorts anything: the email stays as it is until the person saves through POST /crm/inbox/items/:id/sort. Both answer 200 {"item_id","assist_status","related":[...],"assist":null|{...}}.

related lists at most 8 emails, oldest first, only from mailboxes the caller can read and never Automated or Failed-sender-check mail. Each carries id, subject, direction (inbound or outbound), occurred_at, from ({address,name}), to ({address,name} or null), relation (same_conversation, same_sender, same_company, same_reference or duplicate, the same email received again within 10 minutes), position (before or after this email), summary, topic (its sort, or null when not sorted or sent) and thread_key.

When assist is present it carries intent ({en,zh}, what the sender wants), best_guess ({topic,confidence}; topic is null when Atlas has no guess; confidence is high, medium or low), alternative, evidence (verbatim quotes) and evidence_from (an array in the same order as evidence: null when the quote is from the email itself, otherwise the id of the related email it came from), related_notes ([{id,why:{en,zh}}]), recommendation (sort_as, covers, needs_reply, new_topic {label,label_zh} or null, next link_customer or null, text {en,zh}), model and created_at. A quote that is not found in the email or in a related email is dropped, and a guess left without a quote is shown as no guess (best_guess.topic null). Topics are limited to those the organisation offers.

assist_statusMeaning
readyassist is present.
noneGET only: nothing stored yet; call POST once.
unavailableThe AI could not run or answered badly; related is still returned.
credit_pausedThe organisation's AI credit is used up; related is still returned.
sorting_offSorting is switched off for the organisation; related is still returned.
not_neededThe email is already sorted, sent, or Automated or Failed-sender-check mail; related is still returned.

Errors: 404 item_not_found (unknown, another organisation's, or in a mailbox the caller may not read) and 404 not_found when the inbox flag is off.

Which customer an email is from. GET /crm/inbox/items/:id/customer-match uses the same inbox flag and item rules as the sort route above. It never calls AI and writes nothing. It answers 200 {"item_id","candidates":[...],"new_customer":{...},"deal":{...}}. candidates holds at most 3 existing customers the email is likely from, strongest first, each with customer_id, name, owner_email, open_deals, how and evidence. how is contact (a saved contact), domain (the company email domain) or name (the customer's name appears in the email); evidence is the line it matched on. Only customers the caller may see are proposed. new_customer is what a New customer would be prefilled with: name, name_evidence, contact_name, contact_email (each may be null) and contact_addable. deal carries suggested (true for Inquiry, Quotation and Purchase order emails), a title with reply and forward prefixes removed, the entry stage and the organisation's open stages. A sender that failed the provider's sender check is matched to nobody: candidates is empty and contact_addable is false. Errors: 404 item_not_found (unknown, another organisation's, or in a mailbox the caller may not read) and 404 not_found when the inbox flag is off. The screen saves through the existing POST /crm/inbox/links/confirm, the customer create and the deal create; this read changes nothing.

To-dos and Not a customer. On the first page (no cursor) both views also return todo: {"needs_sorting":N,"needs_linking":N,"not_customer":N}. These counts depend only on the caller's readable mailboxes (and connection_id), never on the other filters. needs_sorting is the number of received emails a person must sort: Atlas was unsure, could not sort it, or sorting is switched off (low_confidence plus failed plus sorting_off); needs_linking is the number of conversations of normal mail not linked to a customer and not marked Not a customer, exactly what needs_linking=true lists; not_customer is the number marked Not a customer. not_customer=true lists only conversations (or, in the emails view, emails of conversations) someone marked Not a customer; any other value is 400 invalid_filter.

Unread. Read marks are personal: a colleague opening the same email never marks it read for anyone else. POST /crm/inbox/items/:id/read (no body) records that the caller opened the email. It uses the same inbox flag and item rules as the sort route above, and writes nothing but the caller's own read mark: the email, its sort and every other person's view are unchanged. It is idempotent and answers 200 {"item_id","read_at"}, where read_at (milliseconds) is the time of the first read; a repeat call returns the same value. Errors: 404 {"error":"item_not_found"} (unknown, another organisation's, or in a mailbox the caller may not read) and 404 not_found when the inbox flag is off. A received email is unread until the caller marks it read; a sent email is never unread. A conversation is unread when its latest message is a received email the caller has not read, so our own reply leaves it read. With view, unread=true keeps only unread rows and narrows the rows, counts and hidden_counts like urgent=true; any other value, or unread without view, is refused with 400 {"error":"invalid_filter"}. Every row in both views carries unread (true or false), and both views return counts.unread: the number of unread conversations (or emails) of the chosen kind under the same narrowing as counts.all apart from unread itself, so it does not change when unread=true is turned on. Both views also return counts.needs_reply: the conversations (or emails) of the chosen kind that need a reply, under the same narrowing as counts.all apart from needs_reply itself, so it does not change when needs_reply=true is turned on. direction=outbound is the Inbox's Sent folder: with view=emails it lists sent emails instead of received ones; with view=conversations it lists every conversation holding an email we sent. latest_topic and the other received-mail filters cannot be combined with it. It holds only mail Atlas stored as sent (replies sent from Atlas); the mailbox's own Sent folder is not read yet. It cannot be combined with needs_reply, urgent, unread, covers, needs_linking, not_customer, not_sorted_reason or a kind other than person; any other direction value, or direction with the conversations view or without a view, is 400 invalid_filter.

Not junk. POST /crm/inbox/items/:id/not-junk (no body) moves a junk email (mail_kind=automated) and every other junk email in its thread back to normal mail: each becomes mail_kind=person, and each received one that is not sorted waits for the caller to sort it. It reads not_sorted_reason=low_confidence with classification.cause moved_from_junk, counts in todo.needs_sorting, and is never sorted by Atlas unattended. Who moved which email, and when, is recorded. It uses the same inbox flag and item rules as the sort route above: 200 {"item_id","moved":n}; 400 {"error":"not_junk"} when the email is not junk (including failed sender checks, which stay apart); 404 {"error":"item_not_found"} for an unknown, foreign or unreadable email. Junk and failed-sender-check rows always carry not_sorted_reason: null: Atlas never sorts them.

Need reply until answered. A received email's needs_reply holds only until it is answered: a later outbound email in the same thread, or a "Mark as replied" made after it, clears it, and a new email from the customer brings it back. urgent is a level of needs_reply: it is only ever true when needs_reply is, and clears with it. Both apply to rows, to needs_reply=true / urgent=true and to every count. POST /crm/inbox/items/:id/replied (no body) records that the caller answered the email's thread outside Atlas (Atlas does not read Sent folders): 200 {"item_id","thread_key","marked_at"}; a repeat moves marked_at forward. It writes only that marker: no task event and no contact is recorded (no email_sent), because nothing was sent. The Inbox's Archive uses it and closes the thread's reply task itself. Same inbox flag and item rules as the sort route: 404 {"error":"item_not_found"} for an unknown, foreign or unreadable email.

Inbox Intelligence correction loop (#3595)

POST /crm/communications/:id/classification/correct with {"question_key","corrected_value"} corrects ONE classification answer and visibly re-runs the item — closing the loop AC1 requires without any fine-tuning. corrected_value must already match the question's own shape (boolean for a boolean question, one of options[].key for choice, one of levels[].level for scale) — the same per-kind rule the model's own answers are validated against. Gated inbox-intelligence (404 not_found when off), then org- and rep-owner-scoped exactly like every other per-item communications action (the #2830 named-grant content boundary, then the #1509 owner-scope check on the item). Request-shape refusals — 400 question_key_required, 400 unknown_question_key (not in the organisation's CURRENT active question set), 400 invalid_corrected_value (with reason) — run BEFORE the item-visibility gate, since they depend only on the organisation's question set and leak nothing about whether the item exists; an item this caller may not read still answers 404 item_not_found, and an item with no prior classification at all answers 400 not_classified.

On success the correction is recorded as a comm_triage_corrections audit row (scope classification, prior the item's previous stored value for that key or null, corrected the new value) BEFORE the re-run is attempted — the correction is a fact about what the rep decided regardless of whether the re-run itself then succeeds. The re-run reuses the SAME governed classification pass POST /crm/communications/triage/run uses, pinned to this one item and attributed to the caller's own cookie, with the organisation's own retrieval few-shot examples applied (see below) — then FORCES the corrected question's answer to the rep's own value at high confidence before storing, regardless of what the fresh model call itself returned: an LLM call is not guaranteed to reproduce a value a human just supplied, and this makes the "show the changed stored answer" promise unconditional rather than probabilistic. Every OTHER question's answer reflects the fresh re-run when the model call succeeds; when it fails (credit refusal, an unavailable gateway, an invalid model response), the item's prior full answer set is kept as a floor and only the corrected key changes — a failed re-run never regresses a previously-settled answer, and never leaves the correction looking like it changed nothing. The response is {"comm_item_id","question_key","classification":{"status","question_set_version","answers","cause","model"},"rerun":{"ok","cause"}} — rerun.ok is true only when a live model call actually ran and its (overridden) answers were stored; false names why in rerun.cause (e.g. model_unavailable, credit_refused:<kind>, model_output_invalid) while classification.answers[question_key] STILL equals the submitted value either way. A concurrent classify pass already mid-flight on this same item (another correction, or an unlucky scheduled sweep) answers 409 reclassify_conflict — the correction audit row is already durable at that point, so the caller can simply retry the re-run.

Retrieval (AC2, implemented in inbox-corrections-retrieval.ts): every classification call this organisation makes — the scheduled sweep, the interim user-attributed /triage/run, and this correct-endpoint's own re-run — now includes up to 3 of the organisation's own past classification corrections as few-shot examples, selected deterministically and lexically (no embedding service): a correction is a candidate only when its question key is still in the CURRENT active question set, ranked by plain token overlap between the target email's subject/participants and the candidate's own, ties broken by recency then by item id. Zero-overlap candidates are dropped entirely rather than ranked low. Selected examples are injected into the SAME untrusted-content envelope convention the email body itself uses — a corrected value is still user-authored content entering the prompt. Isolation is absolute: retrieval reads ONLY the calling organisation's own comm_triage_corrections rows, and an item never offers its own past correction as an example of itself. No fine-tuning ever happens anywhere in this loop — corrections remain auditable rows and prompt-side examples only (AC5).

Communication matching evidence

Stored matching snapshots remain organisation-scoped audit records. Every returned link and confirmation receipt filters its nested candidates by current customer ownership and the contact's current customer association, including older stored evidence and repeated requests after reassignment. The committed customer and timeline remain unchanged; inaccessible supporting candidates are omitted from the response.

Match confirmation checks current ownership and contact membership inside its link insert. A lost race cannot return another owner's link or report a different customer/contact pair as successfully selected. A retained confirmation receipt also prevents a missing link from being recreated for a replacement target.

Email publication into CRM

POST /crm/communications/:id/publish copies ONLY an explicitly selected message body and chosen attachments onto one customer record, after the reviewer confirms the exact target customer, message and attachments. Two live authorities are enforced on every attempt: the source mailbox's named read/review grant (live Auth membership and scopes plus the stored comm_account_grants row, re-checked inside the commit — a shared account without the caller's grant and a personal account that is not the caller's refuse 403 mailbox_not_authorized), and the target customer's existing write authority (#1509 owner scope; an out-of-scope customer is the same 404 as an unknown one). The body carries customer_id, optional contact_id, the explicit attachment_ids, idempotency_key (1–128 characters) and optional expected_account_version (409 account_version_changed when the account configuration moved). An attachment id outside the item's manifest refuses 400 unknown_attachment.

The committed snapshot is CRM-owned: one timeline entry (ref_type comm_publication) with a bounded body snapshot, plus separately stored copies of just the selected attachments served under current CRM permissions alone. It names the source account and nothing else about the mailbox — no external item id, no source storage key, no mailbox URL — and it deliberately survives a later disconnect or grant revocation; CRM deletion follows existing policy. The first confirmation answers 201; a repeat answers 200 with the ORIGINAL publication; the same key (or the same item) with a different selection or target refuses 409 idempotency_conflict naming the original. Object copies stage before the commit; a failed copy or a lost authority publishes nothing, cleans the staged keys, and leaves the private source item retryable (503 storage_failed).

DELETE /crm/communications/publications/:publicationId removes the copy: the timeline entry and the attachment copies are deleted and the record tombstoned, and the removal is written to the connection's audit events. Unpublish is CRM deletion — it follows the customer's existing write authority, never the mailbox grant — and a rep outside the customer's book reads the same 404 as an unknown publication. GET /crm/communications/publications/:publicationId/attachments/:partId serves one published copy to any caller who can currently see the target customer.

Redacted account diagnostics and activity

GET /crm/communications/connections/:id/diagnostics projects one account's operational state from stored rows: incoming and outgoing check states with their last checked time, mailbox sync state with its last success and attempt times and synced-item count, the send ledger's last accepted message and last attempt outcome, and the administrative state (lifecycle, setup, and whether a verified human is recorded as the scheduled-sync authority). Every failure surface appears at most once with one closed cause and one next action. Causes close over the fixed vocabulary credential_rejected, access_denied, address_unknown, provider_unreachable, configuration_invalid, storage_unavailable, sync_failed, outcome_unknown plus at most a numeric provider code — never a host, username, password or raw provider line. Null timestamps display as never/unknown and zero counts as zero.

GET /crm/communications/connections/:id/activity?cursor=&limit= returns the account's attributed configuration, check, grant, sync, send, publication and export events, newest first. The opaque cursor is the keyset pair (event timestamp, event id); pages default to 25 rows and never exceed 100, duplicate-millisecond events keep a stable order, and a malformed cursor or limit answers 400. Send attempts surface from the existing send-attempt ledger; nothing is duplicated into a second store. Each item carries only the allowlisted fields — id, kind, actor type, actor id, timestamp, closed cause, optional numeric code, send outcome, and the grant-target fields on grant changes. Raw stored detail strings, message bodies and recipient lists never leave the service.

Both reads require the account's live inspect_setup authority through the same band as every sibling account read; a caller the authority does not admit receives the same 404 as an unknown id, and every query is scoped to the verified session's organisation. GET /crm/communications/connections/:id/diagnostics/export is an explicit account-manager action: it re-checks the live manage authority, answers atlas-account-diagnostics-export/1 — account/version/timing/cause/event metadata only, without actor identifiers or grant targets — as a download, and records an export audit event in the connection's own event store before any bytes leave. An export that cannot be audited does not happen.

Manual connection recovery

POST /crm/communications/connections/:id/test stays the read-only connection check, and now carries the recovery rate bound: at most one bounded operation per account. The check claims the account before any provider work; while any bounded operation (a check or a sync) is active, the route answers 409 {error:"operation_running", operation}, reporting the running operation instead of starting another. Its body, response shape, live manage authority, attributed check audit event and read-only guarantees are unchanged.

POST /crm/communications/connections/:id/sync is the new versioned "sync now": one explicit, bounded mailbox-sync retry for an email account. The body must carry idempotency_key (1–128 characters) and expected_version (the account’s current config version). The run executes through the same lease-holding, recorded-human-authority runner the scheduled sweep uses — no second worker or schedule, no cursor reset, no automatic resume, and never a send. The same idempotency key returns the same operation — the same stable operation id and the same in-flight or closed result — with no second run; a new explicit key may start another bounded run after completion; an expired running row is crash debris a new key moves past, and history is never deleted or reset.

Success answers 200 {connection, account, operation} where operation is {id, operation:"sync", state, result, requested_by, actor_type, created_at, updated_at}, state is running, completed or refused, and result carries closed tokens and counts only — {outcome:"passed"|"failed"|"completed"|"refused"|"skipped", cause, delivered?, duplicate?, quarantined?} — never server text, a host, a credential or a cursor value. Concurrency is refused with 409 {error:"operation_running", operation}, reporting the fresh scheduled-sync lease or the running manual operation; a stale expected_version is 409 {error:"version_conflict", config_version}; a missing or malformed key or version is 400; an unknown id and a caller the live authority does not admit both answer the same 404 as every sibling account action.

Refusals are recorded as inspectable refused operations with closed causes (account_paused, account_disconnected, authority_missing, authority_lost, no_credential, inbound_unavailable, access_revoked, generation_changed, lease_lost, sync_failed) and change nothing — no lifecycle transition, no credential, no cursor. Every admitted sync request appends one attributed, redacted sync_requested event to the connection’s own audit store; evidence is only ever appended. A send whose outcome is unknown stays inspectable through the diagnostics read (send.last_outcome: "outcome_unknown") and the activity log; the recovery path never writes a send command or attempt, and there is no retry-send action — sending again always requires its own explicit approval. Personal accounts stay dark on these routes until the organisation's account model is activated (see Account-model activation): until then every caller receives the same 404.

Account-model activation

The complete account model — personal mailboxes, named grants, private imports, explicit sending authority and recovery — ships dark and is enabled per organisation only through the activation gate. GET /crm/integrations/account-model reports the switch and its readiness: {activated, revision, matrix_revision, activated_at, activated_by, deactivated_at, deactivated_by, readiness:{ready, stale, unverifiable}}. Readiness is the recorded grant backfill revalidated against the live Auth membership/permission revision on every read: a confirmed change reports stale and clears readiness until a fresh backfill, and an unreadable revision reports unverifiable. The same state feeds the Integrations page’s can_create_personal projection, which is what offers (or hides) the personal mailbox choice in the connect wizard.

POST /crm/integrations/account-model/activate and POST /crm/integrations/account-model/deactivate are coordinator-controlled release actions, never browser toggles. The body must carry expected_revision — the switch’s current CAS revision — or the route answers 400 expected_revision_required / 409 {error:"version_conflict", config_version}, and the caller must hold live manage authority, with any other caller answered the band 404. Activation refuses 409 {error:"not_ready"} without the recorded proof, 409 {error:"stale_membership"} when membership moved after the backfill (clearing readiness in the same act), and 503 {error:"unverifiable"} when the live revision cannot be read — the model never opens on an unknown, and a refused activation writes nothing. The accepted guard-matrix revision is recorded on the row, so the release record names the candidate the organisation was enabled on. Deactivation is containment, not a rollback: personal-account creation, sync and send stop, the redacted-audit read restrictions and all history are retained, and no legacy organisation-wide read returns — the privacy guards read the account metadata, never this switch.

Internal session knowledge read admission

The inactive Auth service-binding prerequisite POST /internal/session-read-assertions requires configured AUTH_SECRET, body-bound internal HMAC and an ordinary member session cookie. Bearer tokens, incoming principal assertions and platform sessions are refused. This is not a public REST endpoint and does not activate chat, UI or any catalog adapter.

The exact body is {audience:"ekb",surface:"agent",method:"GET",target,requestId,action,scope:"ekb:read"}, without extra fields. Request IDs contain 1–128 characters; targets are canonical local paths of at most 4096 characters, without a host, fragment or path normalization. Allowed action/target pairs are ekb.knowledge.retrieve → /search, ekb.library.search → /library/search, ekb.releases.list → /releases and ekb.releases.read → /releases/{encoded_release_id}. Queries remain byte-bound and owner-validated; release detail accepts no query.

Auth refreshes the session, organisation, suspension, membership, role and permissions on every call. Missing or malformed authority never falls back to signed stale claims. A rep retains knowledge reads even when knowledge management is denied; view-as can only reduce permission. Success returns {principal,assertion} with existing assertion headers, bound to that exact request for 30 seconds, and no cookie/token text. Failures are 401 unauthorized, 403 organisation_suspended or 503 credential_store_unavailable; issuer responses are no-store, including in development.

EKB derives the organisation only from the ordinary-session principal and rejects mixed credentials or mismatched signatures, route, method, action, query, audience, surface, scope or request ID before knowledge queries. Existing document ranking and current-release eligibility/citations remain unchanged; reference-only releases remain reference-only. Future consumers must obtain fresh issuance for each dispatch and final check. This prerequisite alone grants no mutation, publication, production delivery or Assistant activation.

Knowledge search match mode

GET /search (the ekb.knowledge.retrieve target) accepts an optional match query parameter. The default match=all joins the query tokens with AND, so a longer phrase matches as a phrase lookup. match=any joins the same quoted tokens with OR instead: the response is the BM25-ranked union of candidates sharing any token, for callers whose own pairing step supplies the precision. Any other value is 400 invalid_request; unknown parameters stay rejected. Ranking, active-version eligibility, organisation scoping and every other parameter are unchanged.

Internal complete operational passages

The inactive service-binding read GET /operational/passages requires a fresh strict Auth ordinary-session assertion for ekb.releases.passages, audience ekb, surface agent, scope ekb:read. Cookie-only, mixed credentials and other principal kinds are denied. No public REST/MCP/CLI action, Assistant answer or Sandbox change is activated.

Parameters: required q (2–100 JavaScript units after NFC, trimming and whitespace collapse), optional kind=policy|workflow, and limit=1..8 (default 8). Unknown or repeated parameters and cursors are rejected. Matching uses normalized lowercase substrings, ranking complete block text before policy heading and release title; heading/title matches retain the whole candidate block.

The owner scans the newest 25 current, answerable releases per requested kind. A 26th row sets coverage.capped_kinds; it supplies no evidence. Combined content/locator snapshots are capped at 128 KiB before transfer, with depth 32 and 4096 JSON-value nodes across expanded content and stable IDs. Complete blocks are capped at 16 KiB UTF-8; oversized data is counted, never silently truncated. The response is at most 256 KiB.

Response: {schemaVersion:1,items,coverage:{capped_kinds,oversized_snapshots,oversized_passages,hit_limit,examined_releases}}. Each item contains the owner-qualified reference, asset, persisted locator.stable_id and one-based locator.occurrence:{group,index}, complete text, text_format, release_digest, passage_digest, passage_key, complete:true and answerable:true. Identical blocks may share a stable ID; occurrence and passage key distinguish their positions. Policy bodies and string steps are plain text; other steps are whole JSON values retaining conditions, keys, numbers, booleans and null. Consumers must preserve that structure rather than infer isolated-leaf claims.

Only same-organisation current answerable releases qualify. The owner rechecks selected releases immediately before returning. A verified empty search differs from bounded partial coverage and unavailable storage. Errors have no source payload: 400 invalid_query, 401 unauthorized, 409 source_changed or 503 evidence_unavailable; all responses are no-store. Auth retains 403 organisation_suspended and 503 credential_store_unavailable. Existing short citation/document reads and Sandbox behavior remain unchanged. Consumer activation requires separately verified strict authority and staging acceptance.

Paired method diagnostics

The existing authorised agent.team.inspect detail read may include candidates[].methodComparison for a current Account Manager method candidate. It is a read-only projection from the original immutable campaign report, available to a current authorised human administrator in staging/development. It does not set evaluationId, change candidate state, or alter publication, rollback or golden-baseline gates.

status distinguishes absent (no method evidence, including an intact legacy-only candidate), pending (campaign still running), incomplete (terminal report missing), stale (current source, hire, candidate, skills, profile or policy differs), incompatible (link, admission or report integrity failure), and unavailable (authority or service cannot be verified). A complete projection still requires comparison.compatible; named incompatibilities withhold all affected deltas.

The projection includes the exact manifest digest, report IDs/digests and control/proposed instruction digests. Frozen identities are explicit: control.versionId and proposed.candidateId, candidateRevision, candidateDigest come from the validated original link, not the current or previous active version. Validation and audit partitions retain all three ordinals, per-case fixed/regressed/unchanged/mixed outcomes, ordinal-1 and all-three pass counts. Every original attempt and fallback shows purpose, requested and returned provider/model, applied request effort and its provenance. Unknown execution identity or unverified cleanup withholds both quality and cost comparisons.

usage.control and usage.proposed separate account_review and follow_up_draft, with expected, observed, token-covered, rated, credit-covered and pending attempt counts. Missing or unrated money retains knownCostByCurrency, knownTokens and knownCredits but makes complete totals unknown and withholds costDelta. Explicit zeroModelTrials and unknownTrials are distinct. A comparable cost delta is proposed minus control in the matching currency; it is no winner or activation decision.

summary.latencyMs is the mean, per original trial, of the sum of its exact billed provider-attempt durations, including fallbacks. Its basis is mean_per_trial_sum_provider_attempt_latency, with verified/total trial coverage for each variant. A missing duration remains null. This is provider latency, not end-to-end agent wall time. Results are sampled diagnostic evidence only: no causal, live-model usefulness, customer acceptance or release claim.

Task start times and advance reminders

POST /tasks and PATCH /tasks/:id accept nullable start_at (UTC epoch milliseconds), start_timezone (IANA zone, required with a start), and reminder_minutes (5, 15, 30, 60; null means off). Task responses include all three fields. The date-only deadline due_at remains independent. Set start_at: null to clear the start, timezone and reminder.

CRM's governed handover compensation may send only {status:"cancelled",expected_identity:{customer_id,source:"system",source_id}} to PATCH /tasks/:id. TASKS applies that cancellation atomically only when all three identity fields still match; a changed, missing, or inaccessible task returns 409 task_identity_mismatch and is not modified. Other PATCH callers omit expected_identity.

Validation uses the merged schedule: a reminder requires a start; a changed reminder instant cannot be in the past. Unchanged historical schedules remain editable. Concurrent schedule edits return HTTP 409 schedule_changed; reload the task and reconcile the schedule before retrying. Invalid fields return HTTP 400, including start_at_invalid, start_timezone_invalid, reminder_requires_start_invalid and reminder_time_past_invalid. Clients convert local input to a fixed UTC instant, reject nonexistent DST times and disclose the offset for repeated times.

GET /tasks/reminders atomically creates due start reminders, deduplicated by task, start instant, advance interval and assignee. window_key begins with start:; the nested task carries its schedule. ?feed=all also includes the existing SLA feed. POST /tasks/reminders/:id/seen acknowledges the current permitted assignee's reminder. Read state persists. Rescheduling, disabling, reassignment, completion, cancellation and deletion exclude obsolete start rows from reads and acknowledgement. Rescheduling or clearing a task's due date also deletes its still-unseen overdue/due_soon reminder rows in the same write, so an obsolete due reminder is never delivered and the new schedule earns its own reminder when it next enters the window (STORY-V2-ERP-04). Private start reminders require both creator and assignee to match the caller; managers and other organisations cannot read or acknowledge them. Empty feeds return 200 {items:[]}; inaccessible acknowledgement IDs return 404.

Today and My Tasks poll every 30 seconds while visible and online and refresh on return/reconnect. Normally delivery is within one minute, subject to browser and network availability. No closed-browser or offline alarm is guaranteed; still-valid unread reminders return when online. Existing due/SLA reminders are unchanged. Scheduling is available through these REST endpoints; no new CLI command or MCP tool is introduced.

Task due-day summary (Home and Tasks)

GET /tasks/due-summary?timezone=Asia%2FSingapore returns {timezone, now, dayStart, dayEnd, overdue, today, upcoming, undated} for the signed-in caller’s own open tasks: dayStart/dayEnd are the start of the caller’s current and next local day in the requested IANA timezone (day boundaries follow the zone, including DST transitions), overdue counts due instants before today began, today the rest of the local day, upcoming later instants, and undated open tasks without a due instant. Stored due instants are never rewritten; only the display grouping changes with the timezone. The same authorised scope feeds the Desktop Home summary and the Tasks groups so the two surfaces cannot diverge. An unknown or malformed timezone returns 400 timezone_invalid; no session returns 401. Tasks being merged into another customer are excluded and reported through merge_pending like the list route. This is a read; scheduling remains on the existing task routes and no new CLI command or MCP tool is introduced (STORY-V2-ERP-04).

Task coaching text locale (#2665)

POST /tasks accepts an optional structured coaching payload alongside title/description: coaching_key (a rule id or profile-gap requirement code, up to 80 characters) and coaching_params (an object of up to 20 string/number values, each caller-supplied). Task responses (list and single read) include both fields, null when absent. Sending coaching_params without coaching_key returns HTTP 400 coaching_key.

CRM's suggestion-accept path (POST /crm/suggestions/:id/decide with action: "accept") sets both: it renders the created task's stored title/description in the accepting user's locale (read from the same atlas_locale cookie the web client already writes), and stores the source suggestion's evidence.reason key/params as coaching_key/coaching_params so the web can re-render the row in the viewer's own current locale — which may differ from the locale active at accept time. Tasks written before this shipped, or by any other caller, carry coaching_key: null and show their stored text as-is.

Document PATCH: name, folder and keywords

PATCH /knowledge/files/:id changes a document's name, its folder, or — since #4077 — its keyword list. The body decides what changes: only the keys it mentions are applied, so {"name":"…"} renames, {"folder_id":"…"} moves (null is the library root) and {"keywords":["…","…"]} replaces the whole stored keyword list. The answer is the document's full detail, exactly what the worker kept.

keywords is a replace-all of user-visible keywords: entries are trimmed, empties drop, case-insensitive duplicates collapse to the first spelling, each keyword is 1–40 characters, and at most 32 are kept; anything else is 400 invalid_request. Only a ready document accepts keywords (409 wrong_status otherwise). Authority follows the row's own scope: a company document needs the knowledge-manage level, a personal document its owner; anyone else gets 403 forbidden. A keywords write bumps the document version, so a concurrent re-extract loses the guarded write instead of resurrecting deleted keywords. Neither the write nor the answer changes extraction, passages, search behaviour or the corpus statistics — those still come from the extractor alone.

PDF understanding

GET /knowledge/files/:id adds pdf_extraction for a PDF processed by the active document_pdf_extract skill, or null for older and non-PDF rows. The object records the exact skill id, version and digest; nullable document metadata; page-located passages; and separate text, visual and AI coverage. A partial status and its gaps are authoritative: clients must not imply that uncovered pages, vector graphics or failed image recognition were understood.

POST /knowledge/files/:id/re-extract rereads the current stored bytes. A successful refresh replaces the PDF result and its searchable passages atomically only if the same file version still wins; the prior result remains visible while work is in progress. The first 200 pages are inspected. The skill sends at most one bounded multimodal request for selected raster images through the governed gateway and records the resulting usage before publication.

A terminal extraction error that is safe to retry includes retry_execution_id. On the next explicit user retry, repeat the same operation once with x-atlas-pdf-retry-of: <retry_execution_id>. Reuse that header value for transport retries of the same intent. An unknown, cross-tenant, or different-extraction-basis identity returns 409 invalid_retry_execution. A previously accepted retry identity always reuses its existing child and never advances again; a prior identity without a child may advance only the latest finished failure. An execution_in_progress response carries no retry identity because uncertain work is never run twice.

Passage provenance on the document detail

Each entry of passages in GET /knowledge/files/:id carries optional provenance fields, snake_case exactly as written: page (the 1-based PDF page), kind (text, scan or visual for PDF passages, transcription or description for image passages, table for spreadsheet passages), image_ordinal (the 1-based image ordinal within the page, or null on text and scan passages), part (the 1-based office part: a DOCX's body, header and footer parts in encounter order; a whole PPTX is part 1), line (the 1-based line a text or markdown window starts on) and spreadsheet ({sheet, range, row}: the sheet name, the A1-style range the excerpt covers, and the 1-based row). Every field is optional — absent means unknown, which is what documents extracted before this change and legacy rows return until POST /knowledge/files/:id/re-extract rereads them. A failed document still returns an empty passages array.

Library workspaces and AI organization

GET /crm/knowledge-links?limit=1..200&offset=… is the Library workspace's bounded association index. It returns only customer_id and source_id; managers and admins see visible organisation customers, while representatives see only customers they own. It never copies document metadata from EKB. GET /crm/documents?limit=1..200&offset=… is the matching index for files uploaded on customer profiles (#4231): it returns only customer_id and the document id under the same visibility rule, skipping deleted files and archived customers, so Customers Docs counts profile files alongside linked documents. The files themselves stay CRM documents, read through GET /crm/customers/:id/documents. GET /crm/documents/mine?limit=1..200&offset=… reads the same rows through the uploader's lens instead of the customer's: the files the signed-in member uploaded on customer profiles, newest first, for the Library's My Docs view. One file, two lenses — the row is the same CRM document the customer's Files tab shows, and nothing is copied into Knowledge. Each row carries id, filename, kind, content_type, size_bytes, created_at, customer_id, customer_name, engagement_id and engagement_name (the deal the file was filed under; both null for a customer-level file). A deal name is resolved only from a deal on the document's own customer, so a row whose stored engagement_id points elsewhere returns that id with a null name rather than another customer's deal title. An out-of-range or non-integer limit or offset falls back to the default (100 and 0) instead of erroring, plus a total beside items. It is scoped to the organisation and to uploaded_by; representatives additionally see only customers they own, and deleted files and archived customers are skipped. The storage key is never returned. GET /crm/documents/:id (#4418) answers one document's own metadata the same way — org-scoped, storage key never returned — for the shared document viewer (My Docs, a customer's Files tab, Sales history, the review page) to open a document it has been handed only an id for, without needing the row a list read already carries. A foreign or unknown id is 404 not_found, indistinguishable from each other.

POST /knowledge/library/organize-plan accepts {"file_ids":[...]} for 1–50 unique Library document ids and requires Knowledge write permission. It sends only current file metadata and existing folder ids through the governed Workers AI binding and returns an explained, read-only proposal. Unknown files, fabricated folder ids and malformed model output are rejected. The endpoint never moves a document; the signed-in application applies only user-approved destinations through the existing bulk move route. MCP, CLI and agent adapters are not provided for this UI workflow.

Customer profile gaps

A profile gap is one owner record per open profile field per customer — the CRM's memory of what a note left unanswered (kind: "missing") or what the user promised to supply later (kind: "promised", with a short promise_note). The /today digest, the review queue and the customer-page chips all compose these same rows; they are views, never a second task list. Statuses are open, asked (the assistant may ask about a gap once, ever), answered and skipped.

GET /crm/customers/:id/profile-gaps lists one customer's gaps, unresolved first. POST /crm/customers/:id/profile-gaps upserts up to 12 gaps per request ({"gaps":[{"field_key","kind","promise_note?","payoff_key?"}]}); one row per field key per customer, and a duplicate key inside one request answers 400 duplicate_field_key. Both follow the customer visibility gate: an invisible customer answers 404 on every sub-resource.

GET /crm/profile-gaps?status=live|open|asked|all&limit=1..500 is the bounded org-wide index behind the cross-customer surfaces (default status=live = open + asked, default limit 100). Managers and admins see visible organisation customers, while representatives see only customers they own; each item carries the customer's display name as customer_name, joined from the same row the visibility predicate filtered. An empty result is 200 {"items":[]}.

PATCH /crm/profile-gaps/:id with {"status","value?"} moves a gap. An answered transition may carry a value only for the five real customer columns (main_markets, brands, products, background, customer_type); the value is written through the ordinary customer update — the same validation, the only path — and requires the signed-in user's session: the assistant's principal-assertion path is refused with 403 value_write_requires_session, so an agent can record that it asked but can never write an answer itself. Field keys without a customer column answer 400 field_not_writable for a value; a gap already settled answers 409 gap_already_resolved.

Image understanding

Knowledge accepts JPEG, PNG, WebP, GIF, BMP and safe SVG files up to 8 MiB and 4 million pixels. GET /knowledge/files/:id returns image_extraction for an image processed by document_image_extract; it records the exact skill identity, format, dimensions, detected language, literal capture metadata when present, passages and coverage. GIF coverage states that only the first frame was read. GET /knowledge/files/:id/content returns JPEG, PNG, WebP, GIF and BMP with their allowlisted raster MIME type for an inline Library preview. SVG is served as plain text and is never browser-executed.

Confirm and re-extract use the current stored bytes. A successful guarded result replaces searchable passages and image_result atomically; a failed refresh leaves the prior ready result visible. The reader rejects invalid signatures, unsafe SVG features, over-limit bytes or pixels before any model call. One cache-disabled Gemma request uses the governed gateway and rated usage before publication. Image text is untrusted evidence and never identifies people or grants authority.

Fixture-only quality comparison

Platform superadmins may run POST /knowledge/skill-quality/image-benchmark in the staging quality runner with {fixtureId, arm:"crm_same_model"|"image_skill", batchId, repeatId, skill?}. Only the compiled six fixture IDs are valid. The image_skill arm requires the active image skill id/version/digest; crm_same_model rejects skill and uses the existing CRM transcription prompt on the same governed Gemma route. The response returns fixtureId, fixture digest, arm, run ID, latency and outcome. Repeating the same identifiers replays the cached result and never calls a model again. This endpoint is not a normal user option, accepts no bytes or prompt, and does not publish Library evidence.

Complete spreadsheet knowledge

Additional query budgets: 16,000 cells across selected workbooks and 20,000 characters for the complete answer with citations, shared by Sandbox and Assistant. Publication also bounds stored passages plus the retained window to 1.8 MB. Over-budget work is explicitly refused; narrow the question or split the source. spreadsheet.evaluated_plan records the validated operation and filters used for calculation.

Confirmation and re-extraction publish all occupied cells across every worksheet within parser bounds. File detail adds xlsx_index: {state, source_version, coverage: {cells, rows, sheets, semantic}}. States include complete, partial, failed and legacy_stale. Cell and semantic coverage are separate. Legacy text remains readable, but requires backfill before calculations.

POST /knowledge/library/spreadsheets/query accepts {"question":"Sum Amount in Sales","file_ids":["file-id"]}: 1–20 files, at most 2,000 question characters, and the existing Library read permission. Sandbox and the internal Assistant tool share this EKB result. The governed planner receives schema only; deterministic evaluation uses all matching occupied rows after header row 1. Supported operations: exact lookup, AND filters (equal, unequal, numeric comparisons, contains), row count, sum, average, minimum, maximum and comparison of two groups. Ambiguous fields, missing formula caches, blank/non-numeric numeric inputs, stale indexes and unavailable planning produce actionable refusals. No formulas, macros, SQL or cross-workbook joins execute.

The response adds answer_kind: "spreadsheet", spreadsheet: {status, result, matched_rows, reason?}, and citations carrying file_id, file_name, source_version, sheet, sheet_index, range. All input and predicate ranges are cited. Lookup/filter results over 200 rows or evidence over 1,000 disjoint ranges are refused rather than truncated. Schema over 60,000 characters or searchable text over 1.5 MB requires splitting the workbook.

Manager-only POST /knowledge/library/spreadsheets/backfill accepts {"limit":2} (1–5 per call) and returns {updated, partial, conflicts, failed, remaining}. Complete current indexes and failed or partial source/index records are skipped; every terminal backfill failure is quarantined and requires repair plus explicit re-extraction, so it cannot starve later files. Publication stores the derived object before atomically switching the active pointer and synchronizes a ported compatibility row in the same D1 batch; failed refresh keeps the previous complete index. Apply additive migration 0026_library_xlsx_index.sql before deploying the updated EKB Worker.

XLSX reading and customer preparation

GET /knowledge/files/:id/xlsx reads the retained original workbook through the authenticated Knowledge owner. Existing organisation and Library permissions apply. The response is {"extraction": ...}; reading does not replace the stored source or confirm a CRM import.

QueryMeaning
sheetIndexZero-based sheet index; default 0.
startRow, startColumnOne-based coordinates; default 1.
maxRows, maxColumnsAt most 200 rows and 40 columns per read; these are also the defaults.

The result includes the pinned skill id/version/digest, document metadata, ordered sheet names, original cell coordinates, typed and display values, hyperlink labels and targets, merged ranges, and formulas separately from cached values. Missing cached values are explicit; formulas and external links never execute. coverage.covered, coverage.remaining and continuation identify exact ranges. Repeat the read with the returned continuation; headers retain their original coordinates. Charts expose cached series first. Raster descriptions use governed AI; unavailable visual content produces warnings and partial coverage. A valid empty workbook succeeds. Missing metadata is not provided.

Named parser failures include xlsx_corrupt, xlsx_encrypted, xlsx_legacy_binary and xlsx_unsafe_archive. Resource limits include 400 ZIP entries, 32 MiB expanded total, 8 MiB per entry, plus allocation bounds of 16,000 cells, 64,000 XML tokens, 8 MiB total XML and 2 MiB XML text. Excess is refused, never silently truncated. Skill identity or telemetry failures are reported separately. Failed re-extraction preserves the previous good result and original bytes.

Intake and Assistant ranges

GET /crm/intake-artifacts/:id/reading and the Assistant's read_artifact accept sheet (name), row_from/row_to and col_from/col_to. Row and column bounds are one-based and inclusive. Read at most 200 rows and 40 columns; follow returned complete/remaining metadata instead of assuming the first window is the entire workbook. Keep source locations when quoting or preparing records. These are existing intake/Assistant operations, not additional MCP tool names.

Customer profiling preserves repeated company rows as separate contacts, including blank serial cells and source websites. It does not group companies by email domain. A single-customer preparation that detects several companies returns 422 multiple_customers; the Assistant reroutes to the existing batch review. 422 source_range_required means select a smaller source range for ambiguous input and retry preparation. Review proposed companies, every contact and websites before using the existing explicit commit action. Successful reading or preparation alone never means customers were saved.

Platform organization template onboarding

POST /governance/platform/organisations accepts {orgId, name, adminEmail, templateKey?} from an authorized platform operator. Choose an active key from GET /auth/organisations/templates; omission uses manufacturing-oem. Auth records the source, seeds CRM defaults through its signed owner binding, and then delivers the first-admin invitation. Admission domains and membership rules do not change.

On a seed or invitation failure, the identifier and original details remain reserved, and the organization is inaccessible. This replaces the former delete-on-send-failure behavior for new platform onboarding. The directory exposes onboardingStatus (pending_seed or pending_invitation), onboardingAdminEmail and onboardingTemplateKey. Retry the same POST with the same details, or select Retry setup in Platform → Organizations. Changed details conflict. An in-flight attempt returns 409 onboarding_in_progress; do not submit a different template or delete/recreate the organization. A completed identical request replays without reseeding; delivery retries may send the same invitation more than once.

seed_failed and send_failed report the retained failed step. Invalid/inactive template, invalid input, admission refusal and unrelated existing identifiers are rejected. Already-seeded invitation retries retain their recorded template even if it was retired. A success reports delivered separately: false is not proof of mail delivery. Pending organizations cannot sign in, switch, activate members or bypass setup through ordinary lifecycle controls. Existing organizations and customized values are not reseeded.

DOCX/PPTX shared skills

Library extraction now returns office_extraction: metadata, located blocks/slides, image descriptions, exact coverage, continuation and pinned skill identity. GET /knowledge/files/:id/office reads another bounded window using original owner-stored bytes: DOCX accepts startBlock (1-based), maxBlocks (1–200); PPTX accepts slideIndex (0-based), maxSlides (1–20). It returns {extraction} and does not replace stored evidence. Existing Library permissions apply; failed re-extraction preserves the previous result.

Assistant intake now accepts PPTX alongside DOCX. GET /crm/intake-artifacts/:id/reading accepts block_from/block_to for DOCX and slide_from/slide_to for PPTX (all 1-based). The first block carries office metadata, coverage, continuation and skill identity. Optional skill_version and skill_digest must match retained evidence; mismatches or retired implementations refuse the read. CRM still owns and authorizes attachments. The signed internal EKB POST /ekb/internal/office-extract reauthenticates the forwarded session and performs ephemeral shared extraction; it is not a public upload API.

DOCX uses final-view text and labels headers/footers separately; page numbers are unavailable and declared page counts remain unverified. PPTX uses saved chart data before visual interpretation; no chart rendering infrastructure is added. Unsupported/unreadable content is partial, never silently complete. Task discovery uses compiled catalog applicability and active registry versions, grants no authority and cannot run uploaded code. New versions retain operator activation and retirement. Account Manager reads the same approved owner evidence and emits an account_manager skill receipt.

Contact labels and note context

Settings → Labels separates Company and Contact taxonomies. Manage shared and private Contact sets, then select or create labels while writing a note. Saving adds lasting contact traits and records the note context. Deleting a note retains traits; removing a trait leaves earlier note context intact. Private labels, including their note context, remain visible only to their owner.

  • GET /crm/label-sets?entity_type=contact: Contact sets; omitted target defaults to Company. POST /crm/label-sets accepts immutable entity_type (company or contact). Existing set editing/deletion permissions apply.
  • POST /crm/label-sets/:setId/categories/:categoryId/values with {"name":"Prefers email"}: create a Contact value in a managed category; reuse an existing visible normalized duplicate. Returns {item}.
  • GET /crm/contacts/:id/labels returns {items}. POST with {"value_id":"…"} adds a trait. DELETE /crm/contacts/:id/labels/:valueId removes that trait.
  • POST /crm/contacts/:id/notes and PATCH /crm/contact-notes/:id accept body and optional label_value_ids. PATCH omission preserves context; an explicit array replaces note context and adds lasting traits. Note results include labels. Invalid, hidden, inaccessible or wrong-target values cannot cause partial note writes.

All requests enforce organization, parent-customer visibility, private ownership and existing note-author permissions. A label chip contains value_id, value_name, category_id, category_name, set_id, set_name and visibility. Company metrics and Industry remain Company-only.

GET /governance/platform/skills/:id/quality?orgId=…&version=…&from=…&to=… restricts both aggregates and sealed reports to the selected organisation, skill version and period. Version filtering happens before the 2,000-report limit, so newer versions cannot crowd historical comparison evidence out. Truncation is reported explicitly; currency amounts remain separate and CSV costs retain exact nanos.

Outreach playbook methods: instructions, drafts and withdrawal (#3734)

POST /crm/settings/outreach-playbooks saves a new playbook as an unpublished draft with current_version: 0, draft and draft_saved_at. It creates no published history and cannot match a customer or appear in member guidance. Choose Publish with expected_version: 0 to establish immutable version 1; later publications keep the existing version sequence. Explicit sample adoption remains a publication action.

Playbook editing is a draft flow. PUT /crm/settings/outreach-playbooks/:id/draft (administrator) validates the whole playbook body — name, conditions, touches, period, activity mix, themes, and instructions of at most 4000 characters — and stores it as that playbook's draft with draft_saved_at; the published version, current_version and every version row stay byte-identical. POST /crm/settings/outreach-playbooks/:id/publish with {"expected_version"} consumes the stored draft as the next immutable version in one guarded batch (recording published_by), makes it the version new plans use, and clears the draft; a lost race writes nothing and answers 409 version_conflict carrying the stored current_version, and a publish with no draft answers 409 no_draft. POST /crm/settings/outreach-playbooks/:id/withdraw stamps withdrawn_at and drops the draft: the playbook stops matching customers, its history stays readable, and draft, publish and restore all refuse 409 playbook_withdrawn (repeated withdraw is a no-op). The pre-#3734 publish-on-save PUT /crm/settings/outreach-playbooks/:id no longer exists. Every read and write is scoped to the caller's organisation; the list keeps withdrawn playbooks for history.

Plans record what matched. GET /crm/engagement-plan?customer_id=… now also returns match (the attributes the plan's playbook pinned a match on: tier, customer_class, country, industry) and guidance_changed — true when the plan's recorded playbook version is no longer current (a newer published version, or a withdrawal). An accepted plan always stays on its recorded version; acceptance pins the version the proposal was built from. POST /crm/engagement-plan/refresh with {"customer_id"} is the explicit answer to that flag: it re-proposes from the playbook matching the customer now (keeping completed touches and the last-acceptance record) and stores the new version and match; with no live playbook nothing is written and the response reports unmatched.

Members read the published guidance. GET /crm/engagement-guidance/playbook?customer_id=… (any organisation member, behind the same customer-visibility gate as /engagement-plan) answers {"guidance"|"null", "unmatched"}: the version the customer's plan pinned — or, with no plan, the playbook matching the customer today — with its instructions, conditions, cadence, mix, themes and match. The answer is always an immutable published version; a draft can never appear. Pillar-internal callers use GET /crm/internal/engagement-guidance?org=…&customer_id=…, signed with the shared internal HMAC principal. The internal projection speaks only for what an ACCEPTED plan follows: it returns the accepted plan's exact pinned version with its instructions, themes, cadence and match, or an explicit none with its reason — no_plan (no accepted plan), no_playbook (the pinned version cannot be resolved), or withdrawn, which names the playbook and version under withdrawn while a withdrawn version's instructions are never returned. There is no currently-matching fallback. A customer of another organisation answers the same 404 a member read would.

Touch drafts follow the pinned playbook version (#3741)

POST /agent/engagement-plan/draft with {"customer_id","touch_seq"} now reads the customer's playbook guidance through CRM's internal pinned-guidance projection before any words are written, and the draft cites what it followed. The row and the response gain guidance: {"state":"followed","playbook_id","name","version"} when the draft followed the exact version the accepted plan pinned — a republished playbook does not change drafts under a plan that still cites the old version — or {"state":"none","reason","name?","version?"} when the draft was written without playbook instructions: withdrawn names the playbook and version (X was withdrawn — refresh the plan), while no_plan and no_playbook read No playbook applies. The playbook instructions travel inside the untrusted-content envelope of the governed prompt and are never stored on the row; the draft's model call is recorded under prompt version touch-draft/2. If the guidance cannot be loaded the request answers 503 guidance_unavailable and no draft is made or stored — the rep is asked to try again, and never receives a draft that silently follows other guidance. Today and customer-detail review surfaces render the citation line; rows written before #3741 carry none and render without one.

Governed named human teams

Named human teams use Auth's shared team functions on every surface. New delegated grants auth:teams:read and auth:teams:write are independent: write permits proposing and confirming a reviewed change, not the read tool. Existing tokens gain no authority; request each grant explicitly through POST /auth/tokens. Minting and every read/propose/confirm/recovery require current active owner/admin membership and the corresponding live members:read/members:write capability. Revoked, expired, role-downgraded or unsupported platform/scheduled/sync credentials cannot act.

MCP: auth_teams_read, auth_teams_change_propose, auth_teams_change_confirm, auth_teams_change_reject. Propose takes the change fields plus idempotency_key; confirm/reject take proposal_id and idempotency_key. CLI: atlas organisation teams read '<json>'; atlas organisation teams propose '<json>' --idempotency-key KEY; atlas organisation teams confirm PROPOSAL_ID --idempotency-key KEY; atlas organisation teams reject PROPOSAL_ID --idempotency-key KEY.

REST action transport: POST /assistant/v1/actions/auth.teams.read, POST /assistant/v1/actions/auth.teams.change, and existing POST /assistant/v1/proposals/:id/confirm or /reject. Send Idempotency-Key for proposals and decisions. Results use the existing atlas.action.v1 envelope and the same Auth team/assignment payload. Team changes are administration proposals; policy holds, expiry, rejection and current authority apply before effects. A proposal is bound to its original principal, organisation, normalized input, revision basis and idempotency key. Delegated tokens cannot override policy; an owner decision remains governed by the existing policy flow.

{"operation":"list","status":"active","limit":50}
{"operation":"team","team_id":"TEAM_ID","limit":50}
{"operation":"assignment","email":"[email protected]"}
{"operation":"create","name":"Sales"}
{"operation":"rename","team_id":"TEAM_ID","name":"Customer Success","expected_revision":1}
{"operation":"archive","team_id":"TEAM_ID","expected_revision":2}
{"operation":"assign","email":"[email protected]","team_id":"TEAM_ID","expected_assignment_revision":0,"expected_team_revision":1}
{"operation":"assign","email":"[email protected]","team_id":null,"expected_assignment_revision":1}

Read lists retain status active (default), archived or all, stable ID/email order, opaque cursor, default 50 and maximum 100 rows. Return next_cursor unchanged. Names are trimmed, nonempty and at most 80 Unicode characters. Unknown/extra fields are rejected. Rename/archive require the current expected_revision; assignment requires expected_assignment_revision and, for a non-null destination, expected_team_revision. Removing an assignment must omit the destination revision. Archive requires an empty active team. No role or global membership mutation is exposed.

Auth commits effect, audit and operation receipt atomically. After a timeout/lost response, retry the same proposal decision/key: recovery returns only the matching original result after live admission and never dispatches again, even if that already-applied proposal subsequently expired. A missing receipt cannot bypass expiry. Reused keys/input or stale revisions conflict; revoked authority is forbidden; unavailable authority/storage is retryable failure, never an empty success. All responses are no-store. Receipt migration 0023_organisation_team_operation_receipts.sql, scopes and Auth producer deploy before Agent adapters; public session team REST stays compatible. The binding-only /internal/teams/actions/read and /change/prepare, /change/execute, /change/recover require exact request/body-bound assertions and have no public alias.

A confirmation timeout may leave Auth still committing. The proposal stays pending; rejection and expiry return a conflict while its outcome is unresolved. Retry confirmation only recovers the original receipt and never redispatches. If no receipt becomes available, the proposal remains pending rather than assuming the effect failed.

GET /governance/platform/skills/:id/quality?orgId=…&version=…&from=…&to=… restricts both aggregates and sealed reports to the selected organisation, skill version and period. Version filtering happens before the 2,000-report limit, so newer versions cannot crowd historical comparison evidence out. Truncation is reported explicitly; currency amounts remain separate and CSV costs retain exact nanos.

Workbench customer analytics through MCP and CLI

The read actions crm.workbench.customer_summary.read and crm.workbench.customer_contributors.read use the existing crm:read grant. Ordinary tenant sessions and explicitly granted delegated tokens resolve current company, assigned-team, self or empty scope in Auth before the CRM query and again before returning. A manager without an active named-team assignment receives explicit empty scope. No team-administration grant or confirmation is needed.

MCP exposes crm_workbench_customer_summary_read and crm_workbench_customer_contributors_read as read-only tools. Summary input is exactly {}. Contributors input is {"metric":"customers","scope_revision":"<scope_revision>","limit":50}, with optional opaque cursor. Metrics are customers, active, at_risk and open_pipeline; limit defaults to 50 and accepts 1–100. Extra fields and caller-supplied organisation, owner or scope hints are rejected.

atlas workbench customers summary
atlas workbench customers contributors '{"metric":"open_pipeline","scope_revision":"<revision>","limit":50}'

CLI uses POST /assistant/v1/actions/crm.workbench.customer_summary.read and POST /assistant/v1/actions/crm.workbench.customer_contributors.read, returning the existing action envelope. Forward cursor bytes unchanged and use the matching scope revision. Each contributor page refreshes all four totals from the same current CRM snapshot as its items; pagination does not freeze a historical snapshot.

Results retain customer-current-book-v1, public scope metadata and evaluated_at. Pipeline amount_by_currency keeps whole currency units in separate groups; unknown_amount_count counts missing amounts. Pipeline items identify both customer_id/customer_name and engagement name/ID. The internal owner roster is never returned.

Responses use Cache-Control: no-store. Invalid input/cursor is 400; credential and authority denials retain 401/403; changed scope is 409 scope_changed (read a fresh summary); an unavailable Auth/CRM service is 503, never a fabricated zero. Platform, scheduled and sync callers are unsupported. Public session CRM routes remain session-only; bearer clients use the governed actions. Deploy the exact Auth allowlists and CRM assertion producers before Agent adapters; no new migration or scope is required.

"content": {…}, "stable_ids": {…}, "dependencies": [{…}]}}

GET /knowledge/policies/<id>/releases lists the history newest-first; GET /knowledge/policies/<id>/releases/<releaseId> returns one release — superseded and withdrawn releases stay fetchable there only, as authorised historical evidence. Withdraw runs on the current release alone: POST /knowledge/policies/<id>/releases/<releaseId>/withdraw returns 409 release_not_current for a release that is already withdrawn or no longer current, and removes the current-release pointer in the same transaction.

Conflicts are explicit. A publish whose base_version does not equal the stored draft version answers 409 {"service":"atlas-knowledge","error":"version_conflict"} with zero partial effect; two racing publishes serialize through the release table's uniqueness, and the loser receives the same 409 with nothing written. The item reads (GET /knowledge/policies/<id>, GET /knowledge/workflows/<id>) gain the additive current_release summary, or null when never published; a legacy Published label is not a release. Deleting an asset that has a current release withdraws that release in the same transaction. Reads are all-role; publishes and withdrawals require the knowledge-write boundary (admin and manager); every route is organisation-scoped and a foreign id is the indistinguishable 404. Delegated tokens are refused on all release routes.

Receivable follow-up

A receivable case is the stable identity of money still owed from one source — one recorded transaction or one uploaded invoice document. Below it, dated observations carry what a rep confirmed to still be outstanding: the remaining balance as a decimal string in one currency, the due date or an explicit unknown, and the follow-up state (open, partial, settled, disputed). Confirming appends a new revision and keeps every prior one; a settled observation requires a zero balance and an open or partial one a positive balance. This is follow-up evidence only: nothing posts a payment, and the source transaction or invoice is never modified.

Session routes: GET /crm/customers/:id/engagement/receivables lists cases with their latest observation; GET /crm/customers/:id/engagement/receivables/:caseId returns one case and its full history, newest revision first; POST /crm/customers/:id/engagement/receivables opens a case and confirms its first observation ({source_kind, source_id, remaining_balance, currency, due_on, state}); POST /crm/customers/:id/engagement/receivables/:caseId/observations confirms the next observation and requires expected_revision — a mismatch answers 409 stale_revision and nothing is written. Validation refusals (invalid_balance, invalid_state, currency_mismatch, invalid_source for a cross-customer or missing source, invalid_due_on for a non-existent calendar day, invalid_observed_at for a future timestamp) answer 400 and never modify prior observations. The same surface is available to tokens through the governed actions crm.engagement.receivable.list, crm.engagement.receivable.read and the two-step crm.engagement.receivable.confirm. Deleting the source transaction with DELETE /crm/transactions/:tid closes its case in the same operation: a final settled observation at zero remaining balance carrying the note transaction deleted, appended as a new revision so the earlier observations stay exactly as they were. That delete answers {"deleted": true, "receivable_case_closed": true} when a case was bound and receivable_case_closed: false when none was, and removes the transaction's own timeline entry.

File triage — record a transaction from a document

A rep hands over an invoice, purchase order or receipt through the Documents tab's “Record transaction ▸ From a file” (or an existing Ready file the assistant already holds); Atlas reads it once and proposes a transaction with every value cited to an excerpt — a currency the document never states is left null for the rep, never guessed at CNY. Nothing is written until the rep accepts.

Session routes: POST /crm/customers/:id/documents gains the multipart fields propose ("1" to read the upload — a plain upload without it is stored and never read) and engagement_id (the opportunity to file it under, validated under the customer); a readable format (pdf/jpg/jpeg/png/webp) at or under 10 MiB moves the returned proposal.state to reading, anything else to unsupported immediately. POST /crm/customers/:id/documents/from-artifact files an existing Ready intake artifact — {artifact_id, propose?, engagement_id?, kind?, note?} — as this customer's document instead of a fresh upload, useful when the file already sits behind another intake surface (such as the assistant's file-triage hand-off); a replay of the same artifact for the same customer returns 200 with the document already filed rather than a duplicate, 409 artifact_already_assigned answers a different customer or context, and 404 source_unavailable answers a missing or not-yet-Ready artifact.

GET /crm/documents/:id/proposal returns {proposal} — null when the document was never proposed, otherwise the built proposal: associations (customer, opportunity, kind), values already matched on file, the transaction's fields (each with its source — doc cites an excerpt, atlas is inferred from Atlas's own records, you is left to the rep — and its explanation and marker), contact updates, facts, and leftovers with a suggested action, plus the numbered reading blocks the markers point into. POST /crm/documents/:id/proposal/retry re-reads a document stuck in failed/unreadable/unsupported (202 {state:"reading"}; 409 while already reading). POST /crm/documents/:id/proposal/accept takes the reviewed accept body and is the only write this feature makes: a transaction (carrying source_document_id), confirmed facts, contact field updates (an email is added only if the contact has none, never swapped — a differing cited email is kept as a fact instead), tasks, and the document's opportunity/note — one call, org-scoped, 409 proposal_not_ready before a ready proposal exists and 409 document_already_recorded once a transaction already links the document (a document links to at most one). POST /crm/documents/:id/proposal/dismiss answers {state:"dismissed"} and writes nothing.

GET /crm/customers/:id/documents rows gain engagement_id, proposal_state (null for a plain upload never proposed), and recorded_transaction ({id, amount_cents, currency} or null). GET /crm/customers/:id/transactions rows gain source_document ({id, filename} or null); POST /crm/customers/:id/transactions accepts source_document_id (404 when the document is not under this customer, 409 document_already_recorded when it already links a transaction).

Every customer document answers which deal it belongs to (#4266). customer_documents.engagement_id let NULL mean both “filed at customer level” and “nobody ever asked”, so migration 0115_document_deal_answer.sql adds deal_answer (deal | not_a_deal | pre_atlas, NULL = unanswered) and pre_atlas_ref (the optional reference typed with pre_atlas, at most 80 characters). Existing rows are not back-filled: a row with an engagement and no answer reads as a deal, a row with neither reads as “needs an answer”, and nothing is auto-assigned. The writers keep deal_answer = 'deal' if and only if engagement_id is set.

Three doors write the answer. POST /crm/customers/:id/documents gains the multipart fields deal_answer and pre_atlas_ref; an upload that sends neither an answer nor an engagement_id is stored unanswered rather than guessed at. POST /crm/documents/:id/proposal/accept now REQUIRES deal_answer in the accept body (with opportunity_id when the answer is deal, and an optional pre_atlas_ref), and it also corrects a document still stored as other to the kind the reading named (po/invoice/quote); a kind a person chose is never overwritten. The accept body may also carry document_kind (one of the document kinds: quote, contract, spec, po, sample, invoice, other) — the type the reviewer chose under “What it is”; it is stored as the document's kind, and a document re-typed as a PO on an open deal raises the Won verification like any other PO. An unknown value answers 400 invalid_document_kind. The new PATCH /crm/documents/:id/deal-answer takes {deal_answer, engagement_id?, pre_atlas_ref?} and answers 200 {document} — the only way a file uploaded before the rule ever gets an answer. Refusals are the same on all three: 400 deal_answer_required when no answer is sent, 400 invalid_deal_answer for an unknown one, 400 engagement_required for deal with no engagement, 400 invalid_engagement for a deal belonging to another customer or another organisation, and 404 not_found for a document the caller cannot see. GET /crm/customers/:id/documents and GET /crm/documents/mine rows gain deal_answer and pre_atlas_ref, and the customer listing also gains engagement_title, joined on the document's own customer so no row can borrow another customer's deal name.

#4276 — each upload eligible for automatic reading is checked for a customer and a deal before it is filed, and a document may be neither. POST /crm/customers/:id/documents/from-artifact now also accepts the deal answer fields deal_answer, engagement_id and pre_atlas_ref, validated exactly as the multipart upload validates them: the answer decides, so a deal id sent beside not_a_deal is not stored; a bare engagement_id still means deal; and a promotion that sends no answer at all is stored UNANSWERED rather than defaulted, because the assistant files first and asks on the card afterwards. A deal belonging to another customer or another organisation is 400 invalid_engagement, refused before the artifact is promoted, so nothing is filed.

The assistant's own hand-off no longer refuses a drop of more than one file. Dropping one file or several lands every one of them in the member's My Docs as the file is sent — the same personal-scope POST /knowledge/files that Knowledge › My Docs uses, under the member's own session — creates a review batch for files eligible for automatic reading, and replies with two lines and a single “Review N files” action that opens the review page; no question is asked in the chat thread. Files larger than 50 MiB stay uploaded in My Docs without an automatic review job; the stored-only status is not a reading failure. Each eligible file's light read runs afterwards, asynchronously and resumably from a persisted job rather than inside the reply, so the action appears within a couple of seconds while a 60–90 second read is still queued; the review page shows each file's read state, and a file whose read fails or never completes stays fully reviewable with every document field left to the rep.

Quotation readings (#4591). Newly read proposals also carry optional document_fields: occurred_at, reference, amount, currency and payment_terms, each with the same value, source, confidence, evidence, explanation and marker as a transaction field. A quotation keeps kind opportunity and transaction: null; reading these values creates no transaction and advances no deal stage. In document review, GET /crm/document-review/batches/:id prefers these common readings and falls back to legacy transaction.fields when the new property is absent. Only cited, unambiguous values appear; unresolved citations and unstated values stay blank. Quotation status and received_amount stay blank. Older quotation proposals without these readings need a new read; this change does not backfill them or persist corrections entered in the review form.

Upload association check

The light read behind every upload door (#4276). POST /agent/documents/association-check takes {source: {kind, id}, customer_id?} where kind is intake_artifact or library_file, and answers 200 {read, customer, deal}. It PROPOSES and never decides: calling it writes nothing — no customer, no document, no association — and every caller renders the answer as a pre-filled field marked “read” that a person then confirms with Upload, Confirm or Save.

read carries party_name, party_role, kind_hint, document_kind, total_hint, delivery_date, line_count and customer_type_hint — structured fields only, never the model’s prose. customer.match is one of exact, close, none or no_company: no_company is a SUCCESSFUL read of a document that names nobody (the “Not customer-related” case), while none means it named a company that is not a customer. customer.candidate and customer.target name the matched customer and the customer the caller asked about. deal carries candidates — that ONE customer’s open deals, the ones the document named first — plus proposed_id and how; two deals named is a question, not a coin toss, so proposed_id is null and how reads names two deals.

The organisation comes from the verified session and from nowhere else, and every id is resolved through a binding with the caller’s own session forwarded. Refusals: 401 unsigned; 400 invalid_request for a source that is not one of the two kinds; 404 not_found for an artifact, a Library file or a customer the caller cannot read — the same answer an id that does not exist gets; 422 unreadable when the file has no readable text; and 503 read_unavailable when the governed read could not run, which is never answered as “no company” because a screen would render that as a fact about the document.

A library_file whose stored status is extracting or failed also returns 503 read_unavailable before any model call. Partial or failed extraction never supplies a settled association answer. Legacy files without a stored status retain the existing behavior.

Library file associations

A Library file’s Customer and Deal, recorded WITHOUT moving the file (#4286). Saving a customer on a Library row leaves the bytes in the Library and lists the file on that customer’s Files tab under “From the Library” — one file, one place, two lenses. The answer lives in a CRM-owned link row keyed by the EKB file’s stable display id; there is no foreign key to it, because production EKB is a separate database (ADR 06), and no name, extension or size is stored, because those are resolved through the EKB service binding at read time.

PUT /crm/library-files/:ekbFileId/association takes {state, customer_id?, deal_answer?, engagement_id?, pre_atlas_ref?, read?} and answers 200 {link}. The answer's link carries the joined customer_name and engagement_title — resolved for this response, never stored — so a row rendered straight after Save shows names, never ids; a not_customer_related answer carries both as null. state is customer or not_customer_related; not_customer_related is a complete answer on its own and carries no customer and no deal whatever else was sent, and it is REMEMBERED, so the row is never asked again. The file is resolved through EKB first, so a file the caller cannot see is 404 not_found and acquires no answer, and EKB being unavailable is 503 upstream_unavailable with nothing written. The deal answer is validated exactly as every other door validates it: 400 invalid_state, 400 customer_required, 400 deal_answer_required, 400 invalid_deal_answer, 400 engagement_required, and 400 invalid_engagement for a deal belonging to another customer or another organisation. Saving again REPLACES the answer — one row per file, not a history.

GET /crm/library-files/associations?ids=a,b,c answers 200 {items} with the answers this organisation holds for those ids, at most 100 per request, and 200 {items: []} when none of them is known here. GET /crm/customers/:id/library-files answers 200 {items} of {ekb_file_id, name, ext, size_bytes, deal_answer, engagement_id, engagement_title, pre_atlas_ref, decided_at}. A file this member may no longer see is DROPPED from that list rather than named, and a customer the caller cannot see answers 404 through the same visibility gate every /crm/customers/:id/** route passes — the one answer that discloses nothing at all.

Migration 0126_library_file_associations.sql creates customer_library_file_links, additive and re-applicable.

Association lookup is proof of absence only after every requested batch succeeds. The browser queries all selected IDs in batches of at most 100; a failed batch leaves the whole read unavailable. Database or permission-lookup failures answer 503 association_read_unavailable, never a successful empty result. Actual empty or unauthorized collections remain 200 {items: []}. The association save response includes authorized customer_name and engagement_title from the atomic stored result; it does not reveal names outside the caller’s visibility.

Email attachments (STORY-EMAIL-FILES). Linking a received email to a customer in the Inbox (POST /crm/inbox/links/confirm, POST /crm/inbox/links/confirm-exact, or the automatic link of a reply in a thread a person already linked) files each real document attached to it into the linking member’s My Docs as a draft, and writes its customer_library_file_links row for that customer. The request only queues the email; the CRM cron does the filing within minutes. Inline images with a Content-ID, images under 30 KB, signatures (.p7s), invites (.ics), winmail.dat, forwarded messages (.eml), types the Library does not accept and parts over 10 MB are skipped. A file that already has an answer keeps it. Clearing the email’s link, or relinking it to another customer, withdraws the file from the first customer (removing only the link the filing itself wrote, never an answer a person gave) and files it for the new one. At most 200 files per organisation per day; the rest wait for the next day. Rows of GET /crm/customers/:id/library-files filed this way carry from_email: true, comm_item_id (the email they arrived with) and download_path (the same emailed-copy download as below, for the owner as well). Someone who can see the customer AND read the mailbox the email came from (the same mailbox grant every customer-correspondence view uses) sees such a file even though it lives in another member’s My Docs, provided their role can read the Library; for them the row also carries content_path, and GET /crm/customers/:id/library-files/:ekbFileId/content downloads the emailed copy (200, always Content-Disposition: attachment; 404 not_found for a file not filed from email for this customer, or one its owner deleted; 503 upstream_unavailable when EKB cannot be asked). Migration 0151_comm_attachment_filings.sql creates the comm_attachment_filings queue, additive and re-applicable. Add ?inline=1 to a file's content path to display it in the browser instead of downloading it; this is honoured only when the file's own bytes are a PDF or a PNG, JPEG, WEBP or GIF image, and anything else is still served as a download.

Exhibition context

The verified exhibition facts a rep explicitly confirmed for one customer event — the record promotion and invitations use instead of guessing logistics: event name, start/end (epoch ms), IANA timezone, venue, whose participation is verified (participation.customer / participation.company, each confirmed, not_confirmed or unknown) and whose booth (booth.party of customer, company, an explicit none, or unknown, with an optional booth.label). An unknown state is preserved as unknown — never presented as logistics verified. Every write resolves its 1-10 cited sources ({kind: document|fact, id}, CRM-owned rows under this customer) before anything is stored; each read carries the resolved sources, the confirmed_by and confirmed_at.

Session routes: GET /crm/customers/:id/engagement/exhibition-context lists the customer's records (200 {items:[]} when none); GET /crm/customers/:id/engagement/exhibition-context/:eventId returns one record; POST /crm/customers/:id/engagement/exhibition-context confirms a new event's record (a duplicate event answers 409 event_exists); PUT /crm/customers/:id/engagement/exhibition-context/:eventId re-confirms an existing record (it cannot rename the event); DELETE /crm/customers/:id/engagement/exhibition-context/:eventId withdraws it. Refusals — invalid_timeframe when the end precedes the start, invalid_timezone for a non-IANA zone such as Invalid/Zone, invalid_source for a cite that does not resolve under this customer (another organisation's or a deleted document), invalid_booth_label for a label on a none/unknown booth — answer 400 and never change the prior record. Tokens reach the same surface through the governed read crm.engagement.exhibition-context.read and the propose-confirm-reject effects crm.engagement.exhibition-context.create, .update and .delete.

Tender briefs

The rep-confirmed tender brief for one customer — the reviewable basis tender promotion works from instead of guessing requirements: the caller-chosen tender_reference (unique per customer), a title, a deadline_at instant read in an IANA timezone, a status that is always confirmed with its confirmed_by/confirmed_at, and 1-12 confirmed requirements of at most 500 characters each. Every requirement cites 1-5 resolved confirmed EvidenceRefs (owner:kind/id@version — a CRM record under this customer at its pinned digest, or an EKB Library source dereferenced with the caller’s own credentials through the Service Binding); a requirement that cannot ground itself refuses the whole write, and the model can never add or silently confirm one. An update is an explicit re-confirmation that appends revision N+1 — the prior revision stays readable under /revisions, and the new revision becomes the only basis for new promotion drafts. Stale or withdrawn cited evidence never deletes anything: the read answers eligible_for_new_drafts: false with machine-readable ineligible_reasons until a human re-confirms. Nothing here creates a quotation, sample, price, bid document or submission; quote or sample identifiers inside a requirement text are opaque text only.

Session routes: GET /crm/customers/:id/engagement/tender-briefs lists the customer’s briefs (200 {items:[]} when none); GET /crm/customers/:id/engagement/tender-briefs/:reference returns one brief; GET /crm/customers/:id/engagement/tender-briefs/:reference/revisions returns every preserved revision, newest first (current inclusive); POST /crm/customers/:id/engagement/tender-briefs confirms a new brief (a duplicate reference answers 409 tender_exists); PUT /crm/customers/:id/engagement/tender-briefs/:reference re-confirms it (it cannot rename the reference); DELETE /crm/customers/:id/engagement/tender-briefs/:reference withdraws it with its revisions. Refusals — invalid_timezone for a non-IANA zone such as Asia/Bad, invalid_deadline_at for a non epoch-millisecond deadline, invalid_requirement for unbounded or uncited requirements, invalid_requirement_evidence for a cite that does not resolve as current confirmed evidence under this customer, evidence_unavailable when the knowledge service cannot answer — never change the stored brief. Tokens reach the same surface through the governed read crm.engagement.tender-brief.read and the propose-confirm-reject effects crm.engagement.tender-brief.create, .update and .delete.

Annual cooperation evidence

GET /crm/customers/:id/engagement/annual-evidence?year=YYYY returns one customer's reproducible annual cooperation evidence for a calendar year (UTC bounds, stated in timezone): transaction_groups — one labelled group per status × currency with count and amount_minor, deduplicated by transaction id (a shared invoice reference with two distinct payments counts both); paid_minor_by_currency/outstanding_minor_by_currency measures that exclude cancelled amounts and never combine currencies; the year's activity_timeline (capped at 50 entries with the true activity_total and a coverage.timeline of partial); and outstanding_as_of — the receivable follow-up balance from the latest confirmed observations, a separately dated view that never enters transaction totals. The read is deterministic and write-free: unchanged evidence returns the same digest. Tokens reach the same surface through the governed read crm.engagement.annual-evidence.read.

Engagement drafts

An engagement draft is one customer-bound engagement message waiting in the shared prepared-work queue: a registered typed purpose (follow_up, product_recommendation or tailored_product_solutions), a business reference resolved inside the same customer, and the caller's stable occurrence_key — one row per (customer, purpose, business reference, occurrence), so resubmitting the same occurrence returns the existing draft. Drafts are versioned with compare-and-swap: an editor names the expected_version it last read and a mismatch answers 409 version_conflict without overwriting anyone's decision. Approval binds the exact content, recipient and source_digest: a contact email change or a source edit after creation answers 409 approval_stale with the actionable reason, and an explicit human refresh of an unsent, unlocked draft reruns the purpose on current sources, increments the version, clears approval and preserves the full decision history. An unresolved dispatch locks edit, approve and refresh for the same business key until receipt reconciliation. Scheduled principals cannot approve; preparing or approving content never sends, creates a Task, or updates last contact.

Session routes: GET /crm/engagement-drafts?customer_id=… lists a customer's drafts, GET /crm/engagement-drafts?customer_id=…&draft_id=… returns one draft with its history, POST /crm/engagement-drafts/submit submits an occurrence ({customer_id, purpose, business_ref_kind, business_ref_id, occurrence_key, intent, recipient_contact_id}, intent message | followup | message_and_followup, message intents need a recipient), and POST /crm/engagement-drafts/by-prepared/:preparedWorkId/{edit,approve,dismiss,refresh} decides through the queue row's id. Tokens reach the same surface through the governed read crm.engagement.drafts.read and the two-step crm.engagement.drafts.change (operation: submit, edit, approve, dismiss, refresh).

The Today “Needs your decision” review dialog is a browser client of these same actions: it reads the draft, its history and its dispatch state through crm.engagement.drafts.read and crm.engagement.dispatches.read, decides edit/approve/dismiss through the queue row’s by-prepared routes, and proposes the send and the refresh through crm.engagement.drafts.send and crm.engagement.drafts.change behind the same human confirmation. Copy there is clipboard-only and asserts no contact; a displayed send status comes only from the provider’s recorded answer — acceptance, never delivery — so an unknown outcome offers inspection only, never a retry.

Tailored product solutions (#2118) prepare on demand through the two-step crm.engagement.tailored-solution.prepare ({customer_id, selection_id, recipient_contact_id}): the agent assembles the basis from one product-evidence selection (at most three currently eligible, outward-ready entries, each paired with confirmed CRM fit evidence by deterministic overlap) and the confirmed proposal pins exactly that basis; confirming revalidates it against the current selection and context before CRM's handler re-resolves every fit reference and renders the draft under the caller-stable occurrence key tailored:<customer_id>:<selection_id>. A missing selection, an empty or all-ineligible one, or absent confirmed fit evidence creates no draft and names the gap. Approval and send revalidate the pinned product sources against the current selection, so a retired, replaced or de-attested source is refused with the recommendation named. The outward body names products and fit reasons only — no internal identifier, URL, version or source locator — and preparing never sends.

"content": {…}, "stable_ids": {…}, "dependencies": [{…}]}}

GET /knowledge/policies/<id>/releases lists the history newest-first; GET /knowledge/policies/<id>/releases/<releaseId> returns one release — superseded and withdrawn releases stay fetchable there only, as authorised historical evidence. Withdraw runs on the current release alone: POST /knowledge/policies/<id>/releases/<releaseId>/withdraw returns 409 release_not_current for a release that is already withdrawn or no longer current, and removes the current-release pointer in the same transaction.

Conflicts are explicit. A publish whose base_version does not equal the stored draft version answers 409 {"service":"atlas-knowledge","error":"version_conflict"} with zero partial effect; two racing publishes serialize through the release table's uniqueness, and the loser receives the same 409 with nothing written. The item reads (GET /knowledge/policies/<id>, GET /knowledge/workflows/<id>) gain the additive current_release summary, or null when never published; a legacy Published label is not a release. Deleting an asset that has a current release withdraws that release in the same transaction. Reads are all-role; publishes and withdrawals require the knowledge-write boundary (admin and manager); every route is organisation-scoped and a foreign id is the indistinguishable 404. Delegated tokens are refused on all release routes.

Receivable follow-up

A receivable case is the stable identity of money still owed from one source — one recorded transaction or one uploaded invoice document. Below it, dated observations carry what a rep confirmed to still be outstanding: the remaining balance as a decimal string in one currency, the due date or an explicit unknown, and the follow-up state (open, partial, settled, disputed). Confirming appends a new revision and keeps every prior one; a settled observation requires a zero balance and an open or partial one a positive balance. This is follow-up evidence only: nothing posts a payment, and the source transaction or invoice is never modified.

Session routes: GET /crm/customers/:id/engagement/receivables lists cases with their latest observation; GET /crm/customers/:id/engagement/receivables/:caseId returns one case and its full history, newest revision first; POST /crm/customers/:id/engagement/receivables opens a case and confirms its first observation ({source_kind, source_id, remaining_balance, currency, due_on, state}); POST /crm/customers/:id/engagement/receivables/:caseId/observations confirms the next observation and requires expected_revision — a mismatch answers 409 stale_revision and nothing is written. Validation refusals (invalid_balance, invalid_state, currency_mismatch, invalid_source for a cross-customer or missing source, invalid_due_on for a non-existent calendar day, invalid_observed_at for a future timestamp) answer 400 and never modify prior observations. The same surface is available to tokens through the governed actions crm.engagement.receivable.list, crm.engagement.receivable.read and the two-step crm.engagement.receivable.confirm. Deleting the source transaction with DELETE /crm/transactions/:tid closes its case in the same operation: a final settled observation at zero remaining balance carrying the note transaction deleted, appended as a new revision so the earlier observations stay exactly as they were. That delete answers {"deleted": true, "receivable_case_closed": true} when a case was bound and receivable_case_closed: false when none was, and removes the transaction's own timeline entry.

Exhibition context

The verified exhibition facts a rep explicitly confirmed for one customer event — the record promotion and invitations use instead of guessing logistics: event name, start/end (epoch ms), IANA timezone, venue, whose participation is verified (participation.customer / participation.company, each confirmed, not_confirmed or unknown) and whose booth (booth.party of customer, company, an explicit none, or unknown, with an optional booth.label). An unknown state is preserved as unknown — never presented as logistics verified. Every write resolves its 1-10 cited sources ({kind: document|fact, id}, CRM-owned rows under this customer) before anything is stored; each read carries the resolved sources, the confirmed_by and confirmed_at.

Session routes: GET /crm/customers/:id/engagement/exhibition-context lists the customer's records (200 {items:[]} when none); GET /crm/customers/:id/engagement/exhibition-context/:eventId returns one record; POST /crm/customers/:id/engagement/exhibition-context confirms a new event's record (a duplicate event answers 409 event_exists); PUT /crm/customers/:id/engagement/exhibition-context/:eventId re-confirms an existing record (it cannot rename the event); DELETE /crm/customers/:id/engagement/exhibition-context/:eventId withdraws it. Refusals — invalid_timeframe when the end precedes the start, invalid_timezone for a non-IANA zone such as Invalid/Zone, invalid_source for a cite that does not resolve under this customer (another organisation's or a deleted document), invalid_booth_label for a label on a none/unknown booth — answer 400 and never change the prior record. Tokens reach the same surface through the governed read crm.engagement.exhibition-context.read and the propose-confirm-reject effects crm.engagement.exhibition-context.create, .update and .delete.

Annual cooperation evidence

GET /crm/customers/:id/engagement/annual-evidence?year=YYYY returns one customer's reproducible annual cooperation evidence for a calendar year (UTC bounds, stated in timezone): transaction_groups — one labelled group per status × currency with count and amount_minor, deduplicated by transaction id (a shared invoice reference with two distinct payments counts both); paid_minor_by_currency/outstanding_minor_by_currency measures that exclude cancelled amounts and never combine currencies; the year's activity_timeline (capped at 50 entries with the true activity_total and a coverage.timeline of partial); and outstanding_as_of — the receivable follow-up balance from the latest confirmed observations, a separately dated view that never enters transaction totals. The read is deterministic and write-free: unchanged evidence returns the same digest. Tokens reach the same surface through the governed read crm.engagement.annual-evidence.read.

Engagement drafts

An engagement draft is one customer-bound engagement message waiting in the shared prepared-work queue: a registered typed purpose (follow_up, product_recommendation or payment_collection), a business reference resolved inside the same customer, and the caller's stable occurrence_key — one row per (customer, purpose, business reference, occurrence), so resubmitting the same occurrence returns the existing draft. Drafts are versioned with compare-and-swap: an editor names the expected_version it last read and a mismatch answers 409 version_conflict without overwriting anyone's decision. Approval binds the exact content, recipient and source_digest: a contact email change or a source edit after creation answers 409 approval_stale with the actionable reason, and an explicit human refresh of an unsent, unlocked draft reruns the purpose on current sources, increments the version, clears approval and preserves the full decision history. An unresolved dispatch locks edit, approve and refresh for the same business key until receipt reconciliation. Scheduled principals cannot approve; preparing or approving content never sends, creates a Task, or updates last contact.

Session routes: GET /crm/engagement-drafts?customer_id=… lists a customer's drafts, GET /crm/engagement-drafts?customer_id=…&draft_id=… returns one draft with its history, POST /crm/engagement-drafts/submit submits an occurrence ({customer_id, purpose, business_ref_kind, business_ref_id, occurrence_key, intent, recipient_contact_id}, intent message | followup | message_and_followup, message intents need a recipient), and POST /crm/engagement-drafts/by-prepared/:preparedWorkId/{edit,approve,dismiss,refresh} decides through the queue row's id. Tokens reach the same surface through the governed read crm.engagement.drafts.read and the two-step crm.engagement.drafts.change (operation: submit, edit, approve, dismiss, refresh).

The Today “Needs your decision” review dialog is a browser client of these same actions: it reads the draft, its history and its dispatch state through crm.engagement.drafts.read and crm.engagement.dispatches.read, decides edit/approve/dismiss through the queue row’s by-prepared routes, and proposes the send and the refresh through crm.engagement.drafts.send and crm.engagement.drafts.change behind the same human confirmation. Copy there is clipboard-only and asserts no contact; a displayed send status comes only from the provider’s recorded answer — acceptance, never delivery — so an unknown outcome offers inspection only, never a retry.

"content": {…}, "stable_ids": {…}, "dependencies": [{…}]}}

GET /knowledge/policies/<id>/releases lists the history newest-first; GET /knowledge/policies/<id>/releases/<releaseId> returns one release — superseded and withdrawn releases stay fetchable there only, as authorised historical evidence. Withdraw runs on the current release alone: POST /knowledge/policies/<id>/releases/<releaseId>/withdraw returns 409 release_not_current for a release that is already withdrawn or no longer current, and removes the current-release pointer in the same transaction.

Conflicts are explicit. A publish whose base_version does not equal the stored draft version answers 409 {"service":"atlas-knowledge","error":"version_conflict"} with zero partial effect; two racing publishes serialize through the release table's uniqueness, and the loser receives the same 409 with nothing written. The item reads (GET /knowledge/policies/<id>, GET /knowledge/workflows/<id>) gain the additive current_release summary, or null when never published; a legacy Published label is not a release. Deleting an asset that has a current release withdraws that release in the same transaction. Reads are all-role; publishes and withdrawals require the knowledge-write boundary (admin and manager); every route is organisation-scoped and a foreign id is the indistinguishable 404. Delegated tokens are refused on all release routes.

Receivable follow-up

A receivable case is the stable identity of money still owed from one source — one recorded transaction or one uploaded invoice document. Below it, dated observations carry what a rep confirmed to still be outstanding: the remaining balance as a decimal string in one currency, the due date or an explicit unknown, and the follow-up state (open, partial, settled, disputed). Confirming appends a new revision and keeps every prior one; a settled observation requires a zero balance and an open or partial one a positive balance. This is follow-up evidence only: nothing posts a payment, and the source transaction or invoice is never modified.

Session routes: GET /crm/customers/:id/engagement/receivables lists cases with their latest observation; GET /crm/customers/:id/engagement/receivables/:caseId returns one case and its full history, newest revision first; POST /crm/customers/:id/engagement/receivables opens a case and confirms its first observation ({source_kind, source_id, remaining_balance, currency, due_on, state}); POST /crm/customers/:id/engagement/receivables/:caseId/observations confirms the next observation and requires expected_revision — a mismatch answers 409 stale_revision and nothing is written. Validation refusals (invalid_balance, invalid_state, currency_mismatch, invalid_source for a cross-customer or missing source, invalid_due_on for a non-existent calendar day, invalid_observed_at for a future timestamp) answer 400 and never modify prior observations. The same surface is available to tokens through the governed actions crm.engagement.receivable.list, crm.engagement.receivable.read and the two-step crm.engagement.receivable.confirm. Deleting the source transaction with DELETE /crm/transactions/:tid closes its case in the same operation: a final settled observation at zero remaining balance carrying the note transaction deleted, appended as a new revision so the earlier observations stay exactly as they were. That delete answers {"deleted": true, "receivable_case_closed": true} when a case was bound and receivable_case_closed: false when none was, and removes the transaction's own timeline entry.

Exhibition context

The verified exhibition facts a rep explicitly confirmed for one customer event — the record promotion and invitations use instead of guessing logistics: event name, start/end (epoch ms), IANA timezone, venue, whose participation is verified (participation.customer / participation.company, each confirmed, not_confirmed or unknown) and whose booth (booth.party of customer, company, an explicit none, or unknown, with an optional booth.label). An unknown state is preserved as unknown — never presented as logistics verified. Every write resolves its 1-10 cited sources ({kind: document|fact, id}, CRM-owned rows under this customer) before anything is stored; each read carries the resolved sources, the confirmed_by and confirmed_at.

Session routes: GET /crm/customers/:id/engagement/exhibition-context lists the customer's records (200 {items:[]} when none); GET /crm/customers/:id/engagement/exhibition-context/:eventId returns one record; POST /crm/customers/:id/engagement/exhibition-context confirms a new event's record (a duplicate event answers 409 event_exists); PUT /crm/customers/:id/engagement/exhibition-context/:eventId re-confirms an existing record (it cannot rename the event); DELETE /crm/customers/:id/engagement/exhibition-context/:eventId withdraws it. Refusals — invalid_timeframe when the end precedes the start, invalid_timezone for a non-IANA zone such as Invalid/Zone, invalid_source for a cite that does not resolve under this customer (another organisation's or a deleted document), invalid_booth_label for a label on a none/unknown booth — answer 400 and never change the prior record. Tokens reach the same surface through the governed read crm.engagement.exhibition-context.read and the propose-confirm-reject effects crm.engagement.exhibition-context.create, .update and .delete.

Annual cooperation evidence

GET /crm/customers/:id/engagement/annual-evidence?year=YYYY returns one customer's reproducible annual cooperation evidence for a calendar year (UTC bounds, stated in timezone): transaction_groups — one labelled group per status × currency with count and amount_minor, deduplicated by transaction id (a shared invoice reference with two distinct payments counts both); paid_minor_by_currency/outstanding_minor_by_currency measures that exclude cancelled amounts and never combine currencies; the year's activity_timeline (capped at 50 entries with the true activity_total and a coverage.timeline of partial); and outstanding_as_of — the receivable follow-up balance from the latest confirmed observations, a separately dated view that never enters transaction totals. The read is deterministic and write-free: unchanged evidence returns the same digest. Tokens reach the same surface through the governed read crm.engagement.annual-evidence.read.

Engagement drafts

An engagement draft is one customer-bound engagement message waiting in the shared prepared-work queue: a registered typed purpose (follow_up, product_recommendation, tailored_product_solutions or exhibition_invitation), a business reference resolved inside the same customer, and the caller's stable occurrence_key — one row per (customer, purpose, business reference, occurrence), so resubmitting the same occurrence returns the existing draft. Drafts are versioned with compare-and-swap: an editor names the expected_version it last read and a mismatch answers 409 version_conflict without overwriting anyone's decision. Approval binds the exact content, recipient and source_digest: a contact email change or a source edit after creation answers 409 approval_stale with the actionable reason, and an explicit human refresh of an unsent, unlocked draft reruns the purpose on current sources, increments the version, clears approval and preserves the full decision history. An unresolved dispatch locks edit, approve and refresh for the same business key until receipt reconciliation. Scheduled principals cannot approve; preparing or approving content never sends, creates a Task, or updates last contact.

Session routes: GET /crm/engagement-drafts?customer_id=… lists a customer's drafts, GET /crm/engagement-drafts?customer_id=…&draft_id=… returns one draft with its history, POST /crm/engagement-drafts/submit submits an occurrence ({customer_id, purpose, business_ref_kind, business_ref_id, occurrence_key, intent, recipient_contact_id}, intent message | followup | message_and_followup, message intents need a recipient), and POST /crm/engagement-drafts/by-prepared/:preparedWorkId/{edit,approve,dismiss,refresh} decides through the queue row's id. Tokens reach the same surface through the governed read crm.engagement.drafts.read and the two-step crm.engagement.drafts.change (operation: submit, edit, approve, dismiss, refresh).

The Today “Needs your decision” review dialog is a browser client of these same actions: it reads the draft, its history and its dispatch state through crm.engagement.drafts.read and crm.engagement.dispatches.read, decides edit/approve/dismiss through the queue row’s by-prepared routes, and proposes the send and the refresh through crm.engagement.drafts.send and crm.engagement.drafts.change behind the same human confirmation. Copy there is clipboard-only and asserts no contact; a displayed send status comes only from the provider’s recorded answer — acceptance, never delivery — so an unknown outcome offers inspection only, never a retry.

Tailored product solutions (#2118) prepare on demand through the two-step crm.engagement.tailored-solution.prepare ({customer_id, selection_id, recipient_contact_id}): the agent assembles the basis from one product-evidence selection (at most three currently eligible, outward-ready entries, each paired with confirmed CRM fit evidence by deterministic overlap) and the confirmed proposal pins exactly that basis; confirming revalidates it against the current selection and context before CRM's handler re-resolves every fit reference and renders the draft under the caller-stable occurrence key tailored:<customer_id>:<selection_id>. A missing selection, an empty or all-ineligible one, or absent confirmed fit evidence creates no draft and names the gap. Approval and send revalidate the pinned product sources against the current selection, so a retired, replaced or de-attested source is refused with the recommendation named. The outward body names products and fit reasons only — no internal identifier, URL, version or source locator — and preparing never sends.

"content": {…}, "stable_ids": {…}, "dependencies": [{…}]}}

GET /knowledge/policies/<id>/releases lists the history newest-first; GET /knowledge/policies/<id>/releases/<releaseId> returns one release — superseded and withdrawn releases stay fetchable there only, as authorised historical evidence. Withdraw runs on the current release alone: POST /knowledge/policies/<id>/releases/<releaseId>/withdraw returns 409 release_not_current for a release that is already withdrawn or no longer current, and removes the current-release pointer in the same transaction.

Conflicts are explicit. A publish whose base_version does not equal the stored draft version answers 409 {"service":"atlas-knowledge","error":"version_conflict"} with zero partial effect; two racing publishes serialize through the release table's uniqueness, and the loser receives the same 409 with nothing written. The item reads (GET /knowledge/policies/<id>, GET /knowledge/workflows/<id>) gain the additive current_release summary, or null when never published; a legacy Published label is not a release. Deleting an asset that has a current release withdraws that release in the same transaction. Reads are all-role; publishes and withdrawals require the knowledge-write boundary (admin and manager); every route is organisation-scoped and a foreign id is the indistinguishable 404. Delegated tokens are refused on all release routes.

Receivable follow-up

A receivable case is the stable identity of money still owed from one source — one recorded transaction or one uploaded invoice document. Below it, dated observations carry what a rep confirmed to still be outstanding: the remaining balance as a decimal string in one currency, the due date or an explicit unknown, and the follow-up state (open, partial, settled, disputed). Confirming appends a new revision and keeps every prior one; a settled observation requires a zero balance and an open or partial one a positive balance. This is follow-up evidence only: nothing posts a payment, and the source transaction or invoice is never modified.

Session routes: GET /crm/customers/:id/engagement/receivables lists cases with their latest observation; GET /crm/customers/:id/engagement/receivables/:caseId returns one case and its full history, newest revision first; POST /crm/customers/:id/engagement/receivables opens a case and confirms its first observation ({source_kind, source_id, remaining_balance, currency, due_on, state}); POST /crm/customers/:id/engagement/receivables/:caseId/observations confirms the next observation and requires expected_revision — a mismatch answers 409 stale_revision and nothing is written. Validation refusals (invalid_balance, invalid_state, currency_mismatch, invalid_source for a cross-customer or missing source, invalid_due_on for a non-existent calendar day, invalid_observed_at for a future timestamp) answer 400 and never modify prior observations. The same surface is available to tokens through the governed actions crm.engagement.receivable.list, crm.engagement.receivable.read and the two-step crm.engagement.receivable.confirm. Deleting the source transaction with DELETE /crm/transactions/:tid closes its case in the same operation: a final settled observation at zero remaining balance carrying the note transaction deleted, appended as a new revision so the earlier observations stay exactly as they were. That delete answers {"deleted": true, "receivable_case_closed": true} when a case was bound and receivable_case_closed: false when none was, and removes the transaction's own timeline entry.

Exhibition context

The verified exhibition facts a rep explicitly confirmed for one customer event — the record promotion and invitations use instead of guessing logistics: event name, start/end (epoch ms), IANA timezone, venue, whose participation is verified (participation.customer / participation.company, each confirmed, not_confirmed or unknown) and whose booth (booth.party of customer, company, an explicit none, or unknown, with an optional booth.label). An unknown state is preserved as unknown — never presented as logistics verified. Every write resolves its 1-10 cited sources ({kind: document|fact, id}, CRM-owned rows under this customer) before anything is stored; each read carries the resolved sources, the confirmed_by and confirmed_at.

Session routes: GET /crm/customers/:id/engagement/exhibition-context lists the customer's records (200 {items:[]} when none); GET /crm/customers/:id/engagement/exhibition-context/:eventId returns one record; POST /crm/customers/:id/engagement/exhibition-context confirms a new event's record (a duplicate event answers 409 event_exists); PUT /crm/customers/:id/engagement/exhibition-context/:eventId re-confirms an existing record (it cannot rename the event); DELETE /crm/customers/:id/engagement/exhibition-context/:eventId withdraws it. Refusals — invalid_timeframe when the end precedes the start, invalid_timezone for a non-IANA zone such as Invalid/Zone, invalid_source for a cite that does not resolve under this customer (another organisation's or a deleted document), invalid_booth_label for a label on a none/unknown booth — answer 400 and never change the prior record. Tokens reach the same surface through the governed read crm.engagement.exhibition-context.read and the propose-confirm-reject effects crm.engagement.exhibition-context.create, .update and .delete.

Annual cooperation evidence

GET /crm/customers/:id/engagement/annual-evidence?year=YYYY returns one customer's reproducible annual cooperation evidence for a calendar year (UTC bounds, stated in timezone): transaction_groups — one labelled group per status × currency with count and amount_minor, deduplicated by transaction id (a shared invoice reference with two distinct payments counts both); paid_minor_by_currency/outstanding_minor_by_currency measures that exclude cancelled amounts and never combine currencies; the year's activity_timeline (capped at 50 entries with the true activity_total and a coverage.timeline of partial); and outstanding_as_of — the receivable follow-up balance from the latest confirmed observations, a separately dated view that never enters transaction totals. The read is deterministic and write-free: unchanged evidence returns the same digest. Tokens reach the same surface through the governed read crm.engagement.annual-evidence.read.

Engagement drafts

An engagement draft is one customer-bound engagement message waiting in the shared prepared-work queue: a registered typed purpose (follow_up, product_recommendation, payment_collection, annual_cooperation_report, quotation_followup or next_year_cooperation_plan), a business reference resolved inside the same customer, and the caller's stable occurrence_key — one row per (customer, purpose, business reference, occurrence), so resubmitting the same occurrence returns the existing draft. Drafts are versioned with compare-and-swap: an editor names the expected_version it last read and a mismatch answers 409 version_conflict without overwriting anyone's decision. Approval binds the exact content, recipient and source_digest: a contact email change or a source edit after creation answers 409 approval_stale with the actionable reason, and an explicit human refresh of an unsent, unlocked draft reruns the purpose on current sources, increments the version, clears approval and preserves the full decision history. An unresolved dispatch locks edit, approve and refresh for the same business key until receipt reconciliation. Scheduled principals cannot approve; preparing or approving content never sends, creates a Task, or updates last contact.

Session routes: GET /crm/engagement-drafts?customer_id=… lists a customer's drafts, GET /crm/engagement-drafts?customer_id=…&draft_id=… returns one draft with its history, POST /crm/engagement-drafts/submit submits an occurrence ({customer_id, purpose, business_ref_kind, business_ref_id, occurrence_key, intent, recipient_contact_id}, intent message | followup | message_and_followup, message intents need a recipient), and POST /crm/engagement-drafts/by-prepared/:preparedWorkId/{edit,approve,dismiss,refresh} decides through the queue row's id. Tokens reach the same surface through the governed read crm.engagement.drafts.read and the two-step crm.engagement.drafts.change (operation: submit, edit, approve, dismiss, refresh).

The Today “Needs your decision” review dialog is a browser client of these same actions: it reads the draft, its history and its dispatch state through crm.engagement.drafts.read and crm.engagement.dispatches.read, decides edit/approve/dismiss through the queue row’s by-prepared routes, and proposes the send and the refresh through crm.engagement.drafts.send and crm.engagement.drafts.change behind the same human confirmation. Copy there is clipboard-only and asserts no contact; a displayed send status comes only from the provider’s recorded answer — acceptance, never delivery — so an unknown outcome offers inspection only, never a retry.

Engagement dispatch

Sending an approved engagement message is a reserved, auditable dispatch through the existing email adapter. The two-step effect crm.engagement.drafts.send ({customer_id, draft_id, expected_version}) asks CRM to recheck authority and the exact approval inputs and commit one reservation for that message version; only a positively confirmed reservation precedes the single provider attempt, so a repeat, race, edit, clone or re-prepare of the same customer, purpose and business reference while the send is in flight, unknown or accepted is answered with its recorded dispatch state and never invokes the provider twice. Outcomes are provider_accepted (with the provider's message identifier), definitely_failed (proven before acceptance; an explicit human retry may append a new attempt) and unknown (a timeout or a lost worker; retained, never automatically retried). A provider receipt is recorded as acceptance under a stable dispatch reference that also carries the single idempotent timeline effect — a receipt never displays as recipient delivery. The read crm.engagement.dispatches.read ({customer_id}, optional dispatch_id or draft_id) answers the recorded reservations and their attempts. Scheduled principals, revoked scopes, stale approvals, unavailable sender capability and the organisation's existing 100 messages-per-hour limit are denied.

Follow-up task acceptance (tasks.engagement_followup.create)

Accepting one engagement follow-up proposal creates exactly ONE Task behind the Tasks service, carrying the accepted title, owner, date, customer and — when the business reference is a deal — that deal. Propose with POST /assistant/v1/actions/tasks.engagement_followup.create ({customer_id, draft_id, proposal_id}, plus optional title, due_on (YYYY-MM-DD or null) and assignee_email edits that are recorded as the accepted values beside the originals); confirm with POST /assistant/v1/proposals/:id/confirm and the Idempotency-Key header. The proposal binds the draft version it was reviewed on: an edit, refresh or dismissal between propose and confirm answers 409 proposal_stale and creates no work. Confirming twice, or recovering after a CRM outage once the Task exists, returns the ORIGINAL Task — the identity is the namespaced source_id derived from (organisation, customer, purpose, business reference, occurrence, proposal). Reusing the identity with a different customer, owner or title is a conflict that leaves the created Task untouched. Acceptance never sends a message, never confirms a business outcome, and never marks an existing Task done; a scheduled principal is refused on every adapter.

GET /governance/platform/skills/:id/quality?orgId=…&version=…&from=…&to=… restricts both aggregates and sealed reports to the selected organisation, skill version and period. Version filtering happens before the 2,000-report limit, so newer versions cannot crowd historical comparison evidence out. Truncation is reported explicitly; currency amounts remain separate and CSV costs retain exact nanos.

GET /governance/platform/skills/:id/quality?orgId=…&version=…&from=…&to=… restricts both aggregates and sealed reports to the selected organisation, skill version and period. Version filtering happens before the 2,000-report limit, so newer versions cannot crowd historical comparison evidence out. Truncation is reported explicitly; currency amounts remain separate and CSV costs retain exact nanos.

Engagement schedules

An accepted engagement schedule is one per-customer, per-purpose recurrence rule: a business reference resolved inside the same customer, an explicit first due date (first_due_on), an IANA time zone the date-only due values are compared in (never UTC midnight), and a recurrence of one_time, monthly or event_relative. Monthly recurrence retains its accepted day anchor and clamps to shorter months (Jan 31 → Feb 28/29 → Mar 31). An event_relative schedule counts an explicitly entered lead_days back from one confirmed source date — date_ref_kind/date_ref_id/date_ref_field naming a confirmed order, receivable or quotation date — and stores that source's revision as source_version: a later source edit invalidates the old due basis, due work stops, and a human must re-accept before preparation resumes. Every mutation is versioned with compare-and-swap on expected_revision; a mismatch answers 409 version_conflict and preserves the winner. Pause, cancel and revision stop future preparation without deleting historical occurrences. Saving or changing a schedule prepares nothing, sends nothing and creates no task.

Session routes: GET /crm/customers/:id/engagement-schedules lists the customer's schedules (optional status filter, optional schedule_id for one schedule with its occurrence history), and POST /crm/customers/:id/engagement-schedules applies one operation ({operation, …}: accept with the full basis — purpose, business_ref_kind, business_ref_id, recurrence, first_due_on, timezone, and for event_relative lead_days plus the date reference; update revises named fields; pause and cancel need schedule_id and expected_revision). A customer the caller cannot see answers the same 404 as an unknown one, so a rep without current visibility cannot mutate. Tokens reach the same surface through the governed read crm.engagement.schedules.read and the two-step crm.engagement.schedules.change (operation: accept, pause, cancel, update).

The industry_technology_digest purpose (#2125) schedules a recurring customer-specific industry and technology digest. It is customer-bound (business_ref_kind: "customer" naming the customer itself) and its occurrence identity is schedule/revision/due period, so a revised schedule plans a fresh occurrence. When an occurrence becomes due, the agent gate searches only the organisation’s own knowledge through the EKB binding and keeps at most five passages, each of which must (1) currently carry a confirmed, customer-shareable use attestation for this customer at the exact claim locator (F08) and (2) overlap one of the customer’s confirmed industry or technology facts, which becomes the explicit fit reason (F01). No browser, external feed or research API is ever called. With no qualifying pair the occurrence records the visible skip no_eligible_relevant_source and creates no draft. A prepared draft shows every source, excerpt and fit reason in the review dialog before edit, Copy or explicit Send; approval and send re-resolve every attestation live, and the outward body carries no internal identifier or source locator.

Engagement preparation

Engagement preparation is the bounded behind-the-scenes run state of accepted schedules: for each due business occurrence (identity org/customer/schedule/revision/due period, or org/customer/purpose/business_ref/request_id for an on-demand request), at most one reviewable result exists — an engagement draft, or a visible skipped, failed or stopped state with a structured reason. Claims are durable rows with expiring leases, so racing ticks and retries collapse into one result; missed occurrences collapse so only the latest outstanding one prepares while older ones keep visible superseded reasons. Authority, feature-visibility, schedule-revision and customer-status blocks record the specific stopped state before any effect; a proven failure (for example a vanished source) records an actionable failed state that only the confirmed retry action re-drives. Preparation never sends, never approves, and never creates a Task or a paid balance.

Session routes: GET /crm/customers/:id/engagement-preparations lists the customer's preparation rows (optional status and schedule_id filters), and POST /crm/customers/:id/engagement-preparations prepares one on-demand occurrence ({purpose, business_ref_kind, business_ref_id, request_id, recipient_contact_id?}) — a repeated request_id returns the same result and a new nonce cannot bypass pending work for that business reference. Tokens reach the same surface through the governed read crm.engagement.preparation.read and the two-step crm.engagement.preparation.retry, which re-drives one proven failed row (customer_id, preparation_id) on human confirmation; retry never sends.

Customer needs on a deal

A deal keeps its own customer-needs record: a rep's note — typed on the deal page's Customer needs section, or told to the assistant and confirmed there through a draft_deal_needs card — is stored verbatim, then read by the governed extraction (workflow: "deal_needs") into facts for up to six tracked keys: product, quantity, timeline, target_price, compliance, packaging. Every fact carries an excerpt that is a verbatim substring of the note it came from and a confidence (high/medium/low); a reply whose excerpt cannot be found in the note fails the whole extraction rather than storing a fact nobody can verify. Every fact stored through these routes carries stated_by: "rep" — the note is the rep's own account of the conversation, never a direct quote from the customer; email-sourced (stated_by: "customer") facts are a later scope. A key with no current fact is a gap, never guessed.

At most one current fact exists per key: a later note stating a different value for the same key supersedes the earlier one, unless the two notes landed on the same Asia/Shanghai calendar day, in which case both are kept and both carry conflict: true. A note that restates the same value is a no-op — nothing new is stored.

Session routes: POST /crm/engagements/:id/needs/notes stores one note ({text}, at most 2000 characters) and runs extraction, returning {summary, facts, gaps, note}; a note is never lost when extraction fails — the note keeps its stored text with note.extraction_status: "failed" and a failure_code, and the response still carries the deal's existing facts. GET /crm/engagements/:id/needs reads the deal's current picture: {summary, updated_at, facts[], gaps[], notes[]}, where each fact's source ({kind: "assistant_note", note_id, author, at}) opens the exact note it came from. POST /crm/engagements/:id/needs/notes/:nid/retry re-runs extraction on an existing note's stored text — the note itself is never re-typed. All three routes are scoped to the caller's organisation and require an authenticated session member; there is no separate write path from the assistant, which only ever stages the draft_deal_needs card that posts through the first route above on the rep's own click.

Current quotations (quotation negotiations)

A customer's current quotations are the explicit, audited associations between that customer and the quotations being negotiated with them. An association binds exactly one of: an Atlas quotation revision (quotation_id, one active association per revision), or an external quotation made outside Atlas — a customer document (document_id) plus optional display metadata (quotation_number, revision_label, amount_minor, currency, valid_until). Nothing is inferred: creating a revision, sending a quotation, or uploading a file never places anything in the section, and associating a document never implies it was sent, accepted or under negotiation beyond the association itself. Amounts and currencies are per association; nothing aggregates currencies.

Session routes, all customer-visible-scope guarded (an out-of-scope or foreign customer is the same 404): GET /crm/customers/:id/quotation-negotiations lists active associations with their live joined quotation or document state and a derived expired flag (a sent quotation past its Valid Until stays listed until replaced or removed); POST /crm/customers/:id/quotation-negotiations associates {opportunity_id, quotation_id} or {opportunity_id, document_id, …metadata} — a revision already under negotiation answers 409 conflict, a foreign or mismatched binding 404, malformed metadata 400; DELETE /crm/customers/:id/quotation-negotiations/:negotiationId removes the association explicitly; POST /crm/customers/:id/quotation-negotiations/:negotiationId/replace ({quotation_id}) ends the current association as replaced and starts the successor in one transaction — creating a new revision never moves a negotiation on its own, and two racing mutations serialize so exactly one wins (the loser reads 409 with the winner preserved). When the bound quotation is accepted or rejected through the shipped quotation routes, its association ends automatically with that outcome recorded; the quotation itself stays in the deal's quotation history. Every association, replacement, removal and lifecycle end appends an audit event and a customer-timeline entry (type quotation_negotiation). DELETE /crm/customers/:id/quotations/:qid deletes one DRAFT quotation of this customer outright — its line items, its audit events and its negotiation binding go with it — and answers {"deleted": true}. A quotation that is not a draft answers 409 quotation_not_draft with nothing mutated: a sent quotation's line items are immutable. An unknown quotation id, one belonging to another customer, and one belonging to another organisation all answer the same 404. A quotation that is not a draft is voided instead of deleted (#4194): POST /crm/customers/:id/quotations/:qid/void takes it out of the working lists — this list, Sales history and the deal’s quotation list — while its record, its line items and its events stay exactly as they were, and answers {"voided": true, "changed": true} (changed: false when it was already voided). POST /crm/customers/:id/quotations/:qid/unvoid reverses it, unchanged. A draft answers 409 quotation_is_draft — a draft is deleted, never voided — and the same 404 covers another customer’s and another organisation’s quotation. Voided rows are returned again by GET /crm/customers/:id/quotation-negotiations?include_voided=1 and by GET /crm/engagements/:id/quotations?include_voided=1, which is how a voided quotation is found and brought back. Void is not a status: the quotation still reads sent or accepted, because that is still what the customer was sent. The act is recorded as a customer-timeline entry (type quotation_void), not as a quotation lifecycle event, because it changes no legal fact about the document.

Saved quotation files appear in the same customer Quotation tab. GET /crm/customers/:id/quotation-negotiations retains items for negotiation entries and adds reviewed_files for filed quotation reviews. Each file carries file_id, file_kind (library or customer_document), name, engagement_id, engagement_title, deal_answer, pre_atlas_ref, saved_at and fields (date, reference, amount and currency with their review provenance). Not a deal and Before Atlas remain explicit; filing a quotation creates no revenue, payment or negotiation record. The original permitted file opens from its row. Current file/customer associations govern visibility; unreadable Library sources and deleted documents are omitted, latest saves are deduplicated, and an existing document negotiation suppresses its duplicate file row. Historical saved quotations appear without another Save; without a canonical snapshot only cited document values are returned and the UI labels them From document.

Owner quote decisions

The organisation owner can route large quotes to themselves before anything customer-facing happens. When routing is active, sending a draft quotation whose stored total exceeds the configured threshold — or whose currency differs from the configured threshold currency (Atlas has no FX, so another currency is always held) — answers 409 {"error":"decision_required","decision":{…}} instead of sending: the quotation stays a draft, and a pending decision is created carrying the quotation number, version, total in its currency, requester and requested time. Since #2982 the ledger is keyed by quotation contents — version, total and currency: a still-draft total edited after a hold resolves to its own fresh decision, at most one decision per quotation stays pending, and decided rows are never mutated. The owner approves or rejects per contents: an approved decision admits the requester’s next send of exactly the decided contents (the sent event records decision_id); a rejected one keeps those contents unsent, further sends answer 409 {"error":"decision_rejected"}, and changed or newly created contents are evaluated afresh. If the routing configuration cannot be read, the send fails closed with 503 {"error":"routing_unavailable"} and nothing is written. Inactive routing and totals at or below the threshold send byte-identically to the pre-gate build with no decision rows. Session routes are owner-only (403 {"error":"forbidden"} for any caller without the owner session flag; org-scoped, so a foreign decision id is an indistinguishable 404): GET /crm/owner/quote-decisions lists pending and decided items newest-first with customer name and the quotation’s current status; GET /crm/owner/quote-decisions/routing and PUT /crm/owner/quote-decisions/routing ({active, threshold_minor, currency}) read and set the single per-org configuration; POST /crm/owner/quote-decisions/:decisionId/decide ({outcome: "approved" | "rejected"}) records the decision — decided rows are immutable and offer no other write path. GET /crm/owner/quote-decisions/:decisionId/evidence (#2982) assembles the decision’s one-page evidence pack — deal and quote history, the payment record, the confirmed open balance and the last recorded paid transaction, each section citing its source record ids. Ingredients with no recorded source (per-part last-paid price and cost basis today) render as named gaps, never estimates; a failed source read answers 503 {"error":"pack_assembly_failed"} instead of a partial pack, and the pack is strictly read-only.

Quotation result follow-up and confirmation (#2107)

Record the customer's confirmed answer to one exact sent quotation revision. The answer is one of accepted, rejected, changes_requested or undecided, carried with the customer's feedback text, the observed time and verbatim evidence references. Feedback is stored as CRM-owned versioned notes linked to that revision — never a parallel quotation-status table. An accepted or rejected answer invokes the existing guarded quotation lifecycle actions (#879/#880) and answers with their canonical quotation; it requires at least one evidence reference, and only after the caller's explicit confirmation (a session call is a human decision; the agent surface records only through the propose/confirm ledger). changes_requested preserves the feedback and links the existing revision action without creating anything; undecided keeps the quotation's follow-up eligible. A refusal — unknown quotation, a draft or expired quotation, an already-resolved outcome — persists nothing.

Session routes, all customer-visible-scope guarded: GET /crm/customers/:id/engagement/quotation-result lists the customer's unresolved (effectively Sent) quotations with their latest confirmed note; GET /crm/customers/:id/engagement/quotation-result/:quotationId answers one quotation with its full note history, newest first; POST /crm/customers/:id/engagement/quotation-result/:quotationId records {response, feedback?, observed_at?, source_refs?} — 201 with {quotation, note, effect} where quotation is the canonical lifecycle state for accepted/rejected and effect.revision_action names the separate create-revision action for changes_requested; refusals answer 404/409/400 with their named reason. Agent assertions call the same reads and the exactly-once POST /customers/action/quotation-result-confirm/op_<hash> receipt surface.

Sample selection follow-up and results (#2109)

Follow up one sample's selection result and record the confirmed answer on the A07 sample identity — a customer-linked sample document plus an explicit sample label, derived once into a stable sample_key; there is no fulfilment table. The result is one of selected, not_selected or pending, carried with the observed date, evidence references and the human feedback, plus the product decision: selected and rejected product refs with the stated reasons, or the explicit not-provided marker. Selection is stored and followed up SEPARATELY from the technical test result — neither resolves the other — and a final selected/not_selected stops only that sample's own selection follow-up; unrelated conversations never resolve it. Every product ref must resolve inside this customer's records, and nothing here ever changes an order or product record. A validation refusal persists nothing and closes nothing.

Session routes, all customer-visible-scope guarded: GET /crm/customers/:id/engagement/sample-selection lists the customer's sample-selection cases with their latest confirmed result; GET /crm/customers/:id/engagement/sample-selection/:caseId answers one case with its full observation history, newest first; POST /crm/customers/:id/engagement/sample-selection and POST /crm/customers/:id/engagement/sample-selection/:caseId/observations record {result, observed_at?, feedback?, source_refs?, selected_product_refs?, rejected_product_refs?, reasons?, products_not_provided?} against the case root (opening one when a sample document and label are given) or an existing case — 201 with {case, observation, effect} where effect.cancelled_schedule_ids names the schedules a final result stopped; refusals answer 404/409/400 with their named reason. Agent assertions call the same reads and the exactly-once POST /customers/action/sample-selection-confirm/op_<hash> receipt surface.

Shipment progress follow-up (#2112)

Follow up one shipment of one customer order using its last confirmed carrier and tracking evidence, and show when each status was last verified — the as-of time rides every observation, so a dated record never poses as live carrier tracking. A shipment case hangs off the A09 order identity (the order-delivery case, quoted verbatim as the buyer's order reference) plus an explicit shipment label, derived once into a stable shipment_key; separate labels keep separate histories, and multiple shipments per order never merge. Each confirmed observation records the status — in_transit, exception or delivered — the carrier and tracking reference or an explicit unknown (missing details stay unknown, never guessed), the observed time, cited documents and a note. A delivered status stops ONLY that shipment's accepted order_shipment follow-up; exceptions stay follow-up candidates, and the order-delivery commitment is never consulted or auto-resolved. There is no carrier integration and no ERP write: a validation refusal persists nothing, queries nothing and closes nothing.

Session routes, all customer-visible-scope guarded: GET /crm/customers/:id/engagement/order-shipments lists the customer's shipment cases with their latest confirmed progress and its as-of time; GET /crm/customers/:id/engagement/order-shipments/:caseId answers one case with its full observation history, newest first; POST /crm/customers/:id/engagement/order-shipments and POST /crm/customers/:id/engagement/order-shipments/:caseId/observations record {status, carrier?, tracking_reference?, observed_at?, source_refs?, note?} against the case root (opening one when an order case, label and order document are given) or an existing case whose order and label must match — 201 with {case, observation, effect} where effect.cancelled_schedule_ids names the schedules a delivered status stopped; refusals answer 404/409/400 with their named reason. Agent assertions call the same reads and the exactly-once POST /customers/action/order-shipment-confirm/op_<hash> receipt surface.

Customer platform sales review (#2114)

Review how our products sell on the customer's platforms using confirmed reports only. Each versioned observation is keyed by customer, product, the customer's own platform label and the period, and every measure carries its classification — reported (standing on cited sources), estimated (naming its method and supporting evidence) or missing (stating an explicit reason). Labels never merge, values in different units or currencies stay in separate comparison groups with no grand total, a zero or missing baseline never yields a growth percentage, and A11 check-in observations are reused by reference — counted once, never copied as additional sales. A cited document that has been withdrawn blocks recording and drafting, and no external platform is ever scraped, queried or published to.

Session routes: GET /crm/customers/:id/engagement/platform-sales lists the recorded observations newest-first with the reused evidence resolved once and the like-for-like comparison groups; POST /crm/customers/:id/engagement/platform-sales records one observation. Agent assertions ride the same surface: GET /customers/action/platform-sales-reviews, the exactly-once POST /customers/action/platform-sales-prepare/op_<hash> and POST /customers/action/platform-sales-record/op_<hash> receipt routes. The F06 purpose platform_sales_review (occurrence psales:<customer_id>:<YYYY-MM>) prepares one cited review draft per month; the F05 schedule purpose runs on calendar months or a one-time date only.

Tender product promotion (#2124)

From one current, confirmed tender brief the rep prepares a customer product-promotion draft whose claims map confirmed tender requirements to the approved tailored product evidence. The brief must still resolve, sit before its deadline and re-resolve its pinned evidence as confirmed; a brief past its deadline, stale or inaccessible produces no sendable draft. The draft covers only the requirements the approved tailored basis supports and names the unsupported requirements to the reviewer. Communication only — never a bid submission, no compliance, price, availability or bid-acceptance promise beyond evidence. Agent assertions ride POST /customers/action/tender-product-promotion-prepare/op_<hash>: the caller-stable request_id replays the same occurrence, and a new request waits while an earlier send for the same tender (business key org/customer/tender_product_promotion/customer:<tender_reference>) is reserved, dispatching or unknown.

Quotation draft from approved evidence (#2742)

The Pre-sale Assistant may propose ONE quotation draft on an open deal of the customer it is hired to. The propose-time basis is the same bounded product-evidence basis the tailored solution assembles (at most three currently eligible entries, each tied to a confirmed CRM fit fact); confirming creates exactly one quotation in status draft through the shipped per-deal create, with one line per evidence entry. Two invariants hold on every path: the agent never sets a price — every line lands at unit price 0 with the draft's notes saying so, for a person to price — and nothing here sends, marks a quotation sent, or touches its status. A closed (won or lost) deal, a deal of another customer, and a deal of another organisation are all refused before anything is created. Agent assertions ride the exactly-once POST /customers/action/quotation-draft-prepare/op_<hash> receipt route, whose idempotency receipt is claimed before the quotation is created, so a replayed confirmation answers the one draft instead of creating a second. Tokens reach it through the two-step crm.engagement.quotation-draft.prepare ({customer_id, opportunity_id, selection_id, valid_until, currency?}), which requires human confirmation at every permission level.

Deal milestones and samples (#4282)

Three facts on a deal are derived from records and never set directly: GET /crm/engagements/:id/milestones returns {quoted, sample_sent, po_received, quoted_draft}. quoted and sample_sent are each null or {at, ref_id, label} for the earliest qualifying record — a quotation with sent_at set and no voided_at, or a sample with shipped_at set. quoted_draft is true when a draft quotation exists on the deal even though nothing has been sent yet, so the deal page can read "draft, not sent" instead of a bare "Not yet". po_received is the earliest non-deleted customer document of kind po linked to this deal (engagement_id set to it), or — when the file carries no engagement_id at all — the customer's only open deal; a PO on a customer with several open deals and no explicit link counts for none of them, so it is never attributed to the wrong one. A deal outside the caller's organisation answers 404 {"error":"not_found"}; a deal with no qualifying record returns 200 with nulls. No endpoint accepts a milestone value directly: voiding the quotation, deleting the sample, or removing the file returns its row to null.

Samples are recorded through POST /crm/engagements/:id/samples {description, shipped_at?, tracking?}, returning 201 with the created record; GET /crm/engagements/:id/samples lists every non-deleted sample on the deal, newest first. A sample recorded without shipped_at is prepared, not yet shipped, and does not satisfy the milestone until PATCH /crm/engagements/:id/samples/:sampleId sets it; the same route edits description and tracking. DELETE /crm/engagements/:id/samples/:sampleId soft-deletes the record, which returns sample_sent to null if it was the milestone. A sample id that does not belong to the named deal answers the same 404 a nonexistent one would. Every sample route requires the same organisation membership the deal's stage change requires, and every read and write is scoped to the deal's organisation.

Won verification (#4285)

Atlas watches for a purchase order, extracts it, and lays it beside the deal; it never writes Won itself. GET /crm/engagements/:id/verification returns 200 always for a deal the caller can read — {verification: null} when none is pending, or the pending record (its document carries {id, filename, source}, where source is null for an uploaded file and, for a purchase order filed from an email, {kind: "email", comm_item_id, sender_name, sender_email, subject, received_at} — the sender of the email, not the person the file was credited to) plus an optional comparison array of {field, po_value, deal_value, match} rows, one per customer / product / quantity / amount / delivery / terms (match is true, false, or "unknown" whenever either side is null — never a guess); a deal outside the caller's organisation answers 404. POST /crm/engagements/:id/verification/:vid/confirm {set_amount_from_po?, set_close_today?, partial?} is the only route that writes Won, and only the deal's owner or an org owner/admin/manager may call it — anyone else gets 403 forbidden; it returns {ok: true, status: "confirmed" | "confirmed_partial"}, and a verification already resolved answers 409 {"error":"already_resolved"}. POST /crm/engagements/:id/verification/:vid/decline {reason, note?} records why this PO does not mark the deal Won, from the fixed set sample_or_tooling / under_review / wrong_deal / still_negotiating — an unknown value answers 400 {"error":"invalid_reason"}, a note over 300 characters answers 400 {"error":"note_too_long"} — and returns {ok: true, status: "declined"}. No route ever moves a deal to Won on its own: a found PO only sets pending_verification: {id, po_number} on GET /crm/engagements/:id, and a deal moved to Won by hand, without ever carrying a verified PO, reads won_without_po: true there instead — both fields are present only on the single-deal read.

GET /crm/today's response carries a to_verify array beside the existing items, one row per deal with a pending verification: {engagement_id, title, customer_name, po_number, amount, currency, created_at}. Each row in GET /crm/team/members's members gains unverified_wins — the count of this member's deals still sitting in "Won · verify" after 3 working days, 0 when none. Migration 0121_engagement_verifications.sql adds the table backing all of this.

Personal mailbox setup

GET /crm/integrations?accounts=omit exposes safe Email and enabled Gmail setup metadata to active members. can_create_personal requires live CRM write access and no explicit organisation containment; shared creation still requires integrations management. Personal OAuth stores its account kind with the single-use state, derives the owner again at callback, and never converts an existing shared account. GET /crm/inbox/accounts includes account_kind as personal, shared, or null when no trusted metadata exists. This field describes visibility; it grants no authority. Shared grants, send approvals, credential checks and existing dormant accounts retain their gates.

Platform assisted setup (Integrations)

Three roster-guarded endpoints let a platform superadmin act on one organisation's communication integrations; the organisation is taken from the path and every action is audited. GET /crm/platform/organisations/{orgId}/integrations returns the provider catalogue and the organisation's connections, each connection carrying its state and, when Atlas support set it up, its support_attribution. POST /crm/platform/organisations/{orgId}/integrations/{provider}/connect performs the guided connect exactly as the organisation's administrator would, with the Atlas-support actor; POST /crm/platform/organisations/{orgId}/integrations/{connectionId}/test runs the organisation's connection check. All three require a verified platform superadmin session — organisation roles never authorise them — and refuse with 403 otherwise. No response ever carries a stored credential; the audit append is a precondition, and a refused append answers 503 with nothing performed. Since #1870 the email provider connects through the same flow: the guided connect seals the whole email credential set — incoming and outgoing server each with its port and security setting, the mailbox account, its app password, the From address and the history window — refuses port 25 and any port/security disagreement before anything is written, and the connection check's check payload gains observedCount, windowDays and a closed cause token for email connections. No response or audit line ever carries the app password.

Seat allowances and employee wallet consent

POST /governance/platform/billing/allowance-grants requires a verified platform-superadmin session and same-origin JSON. The body is {"orgId","reason","requestId"}. For the Shanghai calendar month containing the request time the action reads the organisation's operator-maintained subscription and, for every active seat Auth reports, appends to that seat's allowance:<email> wallet at most one reset entry (zeroing whatever balance the period inherited) and exactly one grant entry of allowance_tokens_per_seat. Deterministic idempotency keys allowance-grant/allowance-reset plus the ledger's UNIQUE (org, key) constraint mean a repeated run for the same period appends nothing further, and a replayed requestId returns the recorded summary. A missing subscription or a null per-seat allowance answers {"allowancePerSeat":null,"seats":[]} and appends nothing; the amount never lives in code. The response is {orgId, periodStart, periodEnd, allowancePerSeat, seats:[{email,wallet,grantedTokens,resetTokens}], replayed} — HTTP 201 when entries were appended, 200 on replay — and one wallet.allowance_apply platform audit row records the before/after wallet summary.

Consent is self-service: POST /governance/billing/wallet-opt-in records the signed-in member's own consent (with actor and timestamp) and DELETE /governance/billing/wallet-opt-in withdraws it. Both are idempotent and refuse with 403 when a body email names anyone but the caller; no admin may record consent for another member. GET /governance/billing adds optIns.self for callers with billing read and, for billing admins, optIns.members listing every member wallet joined with its consent state. Consent gates only the employee's own wallet in the shipped charge order — without a recorded opt-in that wallet is never debited; the seat allowance and organisation wallet are unaffected.

Engagement schedules

An accepted engagement schedule is one per-customer, per-purpose recurrence rule: a business reference resolved inside the same customer, an explicit first due date (first_due_on), an IANA time zone the date-only due values are compared in (never UTC midnight), and a recurrence of one_time, monthly or event_relative. Monthly recurrence retains its accepted day anchor and clamps to shorter months (Jan 31 → Feb 28/29 → Mar 31). An event_relative schedule counts an explicitly entered lead_days back from one confirmed source date — date_ref_kind/date_ref_id/date_ref_field naming a confirmed order, receivable or quotation date — and stores that source's revision as source_version: a later source edit invalidates the old due basis, due work stops, and a human must re-accept before preparation resumes. Every mutation is versioned with compare-and-swap on expected_revision; a mismatch answers 409 version_conflict and preserves the winner. Pause, cancel and revision stop future preparation without deleting historical occurrences. Saving or changing a schedule prepares nothing, sends nothing and creates no task.

Session routes: GET /crm/customers/:id/engagement-schedules lists the customer's schedules (optional status filter, optional schedule_id for one schedule with its occurrence history), and POST /crm/customers/:id/engagement-schedules applies one operation ({operation, …}: accept with the full basis — purpose, business_ref_kind, business_ref_id, recurrence, first_due_on, timezone, and for event_relative lead_days plus the date reference; update revises named fields; pause and cancel need schedule_id and expected_revision). A customer the caller cannot see answers the same 404 as an unknown one, so a rep without current visibility cannot mutate. Tokens reach the same surface through the governed read crm.engagement.schedules.read and the two-step crm.engagement.schedules.change (operation: accept, pause, cancel, update).

The industry_technology_digest purpose (#2125) schedules a recurring customer-specific industry and technology digest. It is customer-bound (business_ref_kind: "customer" naming the customer itself) and its occurrence identity is schedule/revision/due period, so a revised schedule plans a fresh occurrence. When an occurrence becomes due, the agent gate searches only the organisation’s own knowledge through the EKB binding and keeps at most five passages, each of which must (1) currently carry a confirmed, customer-shareable use attestation for this customer at the exact claim locator (F08) and (2) overlap one of the customer’s confirmed industry or technology facts, which becomes the explicit fit reason (F01). No browser, external feed or research API is ever called. With no qualifying pair the occurrence records the visible skip no_eligible_relevant_source and creates no draft. A prepared draft shows every source, excerpt and fit reason in the review dialog before edit, Copy or explicit Send; approval and send re-resolve every attestation live, and the outward body carries no internal identifier or source locator.

Engagement preparation

Engagement preparation is the bounded behind-the-scenes run state of accepted schedules: for each due business occurrence (identity org/customer/schedule/revision/due period, or org/customer/purpose/business_ref/request_id for an on-demand request), at most one reviewable result exists — an engagement draft, or a visible skipped, failed or stopped state with a structured reason. Claims are durable rows with expiring leases, so racing ticks and retries collapse into one result; missed occurrences collapse so only the latest outstanding one prepares while older ones keep visible superseded reasons. Authority, feature-visibility, schedule-revision and customer-status blocks record the specific stopped state before any effect; a proven failure (for example a vanished source) records an actionable failed state that only the confirmed retry action re-drives. Preparation never sends, never approves, and never creates a Task or a paid balance.

Session routes: GET /crm/customers/:id/engagement-preparations lists the customer's preparation rows (optional status and schedule_id filters), and POST /crm/customers/:id/engagement-preparations prepares one on-demand occurrence ({purpose, business_ref_kind, business_ref_id, request_id, recipient_contact_id?}) — a repeated request_id returns the same result and a new nonce cannot bypass pending work for that business reference. Tokens reach the same surface through the governed read crm.engagement.preparation.read and the two-step crm.engagement.preparation.retry, which re-drives one proven failed row (customer_id, preparation_id) on human confirmation; retry never sends.

Current quotations (quotation negotiations)

A customer's current quotations are the explicit, audited associations between that customer and the quotations being negotiated with them. An association binds exactly one of: an Atlas quotation revision (quotation_id, one active association per revision), or an external quotation made outside Atlas — a customer document (document_id) plus optional display metadata (quotation_number, revision_label, amount_minor, currency, valid_until). Nothing is inferred: creating a revision, sending a quotation, or uploading a file never places anything in the section, and associating a document never implies it was sent, accepted or under negotiation beyond the association itself. Amounts and currencies are per association; nothing aggregates currencies.

Session routes, all customer-visible-scope guarded (an out-of-scope or foreign customer is the same 404): GET /crm/customers/:id/quotation-negotiations lists active associations with their live joined quotation or document state and a derived expired flag (a sent quotation past its Valid Until stays listed until replaced or removed); POST /crm/customers/:id/quotation-negotiations associates {opportunity_id, quotation_id} or {opportunity_id, document_id, …metadata} — a revision already under negotiation answers 409 conflict, a foreign or mismatched binding 404, malformed metadata 400; DELETE /crm/customers/:id/quotation-negotiations/:negotiationId removes the association explicitly; POST /crm/customers/:id/quotation-negotiations/:negotiationId/replace ({quotation_id}) ends the current association as replaced and starts the successor in one transaction — creating a new revision never moves a negotiation on its own, and two racing mutations serialize so exactly one wins (the loser reads 409 with the winner preserved). When the bound quotation is accepted or rejected through the shipped quotation routes, its association ends automatically with that outcome recorded; the quotation itself stays in the deal's quotation history. Every association, replacement, removal and lifecycle end appends an audit event and a customer-timeline entry (type quotation_negotiation). DELETE /crm/customers/:id/quotations/:qid deletes one DRAFT quotation of this customer outright — its line items, its audit events and its negotiation binding go with it — and answers {"deleted": true}. A quotation that is not a draft answers 409 quotation_not_draft with nothing mutated: a sent quotation's line items are immutable. An unknown quotation id, one belonging to another customer, and one belonging to another organisation all answer the same 404. A quotation that is not a draft is voided instead of deleted (#4194): POST /crm/customers/:id/quotations/:qid/void takes it out of the working lists — this list, Sales history and the deal’s quotation list — while its record, its line items and its events stay exactly as they were, and answers {"voided": true, "changed": true} (changed: false when it was already voided). POST /crm/customers/:id/quotations/:qid/unvoid reverses it, unchanged. A draft answers 409 quotation_is_draft — a draft is deleted, never voided — and the same 404 covers another customer’s and another organisation’s quotation. Voided rows are returned again by GET /crm/customers/:id/quotation-negotiations?include_voided=1 and by GET /crm/engagements/:id/quotations?include_voided=1, which is how a voided quotation is found and brought back. Void is not a status: the quotation still reads sent or accepted, because that is still what the customer was sent. The act is recorded as a customer-timeline entry (type quotation_void), not as a quotation lifecycle event, because it changes no legal fact about the document.

Personal mailbox setup

GET /crm/integrations?accounts=omit exposes safe Email and enabled Gmail setup metadata to active members. can_create_personal requires live CRM write access and no explicit organisation containment; shared creation still requires integrations management. Personal OAuth stores its account kind with the single-use state, derives the owner again at callback, and never converts an existing shared account. GET /crm/inbox/accounts includes account_kind as personal, shared, or null when no trusted metadata exists. This field describes visibility; it grants no authority. Shared grants, send approvals, credential checks and existing dormant accounts retain their gates.

Platform assisted setup (Integrations)

Three roster-guarded endpoints let a platform superadmin act on one organisation's communication integrations; the organisation is taken from the path and every action is audited. GET /crm/platform/organisations/{orgId}/integrations returns the provider catalogue and the organisation's connections, each connection carrying its state and, when Atlas support set it up, its support_attribution. POST /crm/platform/organisations/{orgId}/integrations/{provider}/connect performs the guided connect exactly as the organisation's administrator would, with the Atlas-support actor; POST /crm/platform/organisations/{orgId}/integrations/{connectionId}/test runs the organisation's connection check. All three require a verified platform superadmin session — organisation roles never authorise them — and refuse with 403 otherwise. No response ever carries a stored credential; the audit append is a precondition, and a refused append answers 503 with nothing performed. Since #1870 the email provider connects through the same flow: the guided connect seals the whole email credential set — incoming and outgoing server each with its port and security setting, the mailbox account, its app password, the From address and the history window — refuses port 25 and any port/security disagreement before anything is written, and the connection check's check payload gains observedCount, windowDays and a closed cause token for email connections. No response or audit line ever carries the app password.

Seat allowances and employee wallet consent

POST /governance/platform/billing/allowance-grants requires a verified platform-superadmin session and same-origin JSON. The body is {"orgId","reason","requestId"}. For the Shanghai calendar month containing the request time the action reads the organisation's operator-maintained subscription and, for every active seat Auth reports, appends to that seat's allowance:<email> wallet at most one reset entry (zeroing whatever balance the period inherited) and exactly one grant entry of allowance_tokens_per_seat. Deterministic idempotency keys allowance-grant/allowance-reset plus the ledger's UNIQUE (org, key) constraint mean a repeated run for the same period appends nothing further, and a replayed requestId returns the recorded summary. A missing subscription or a null per-seat allowance answers {"allowancePerSeat":null,"seats":[]} and appends nothing; the amount never lives in code. The response is {orgId, periodStart, periodEnd, allowancePerSeat, seats:[{email,wallet,grantedTokens,resetTokens}], replayed} — HTTP 201 when entries were appended, 200 on replay — and one wallet.allowance_apply platform audit row records the before/after wallet summary.

Consent is self-service: POST /governance/billing/wallet-opt-in records the signed-in member's own consent (with actor and timestamp) and DELETE /governance/billing/wallet-opt-in withdraws it. Both are idempotent and refuse with 403 when a body email names anyone but the caller; no admin may record consent for another member. GET /governance/billing adds optIns.self for callers with billing read and, for billing admins, optIns.members listing every member wallet joined with its consent state. Consent gates only the employee's own wallet in the shipped charge order — without a recorded opt-in that wallet is never debited; the seat allowance and organisation wallet are unaffected.

GET /governance/billing/windows (#2609) reports the signed-in account's rolling usage windows: the recorded plan tier and, per window (5-hour and 7-day), the customer credits summed from the usage ledger for the caller's email across their organisations, the tier limit, and the reset time. Callers with billing read see their own state; billing admins additionally receive members, every ledger-visible member of the organisation with the same window state. Without the billing read the route answers the empty shape ({"tier":null,"self":null,"members":[]}); limits come from the recorded plan_tiers row, never from code.

Reviewed-eligibility marks and the eligible read

Stewards (admins and managers) decide when an approved Information record may be recalled by authorised services. POST /governance/memory-review/records/:recordId/review-eligible with an empty JSON body writes exactly one reviewed-eligibility mark for an active, approved record of the steward's own review purpose; the response carries the record and the mark. The mark is one-way: it ends only when the record is restricted, superseded or erased, and no existing record is ever marked or returned automatically.

Each request mints a fresh idempotency key, so marking an already-marked record answers 409 {"error":"already_marked"} as its terminal result — repeat deliberately, never silently. A missing record or one from another organisation answers 404; a record that is not active or was approved under a different purpose answers 409; any non-empty body answers 400. A representative (member role) POST answers the standard 403 refusal and changes nothing.

GET /governance/memory-review/eligible returns only marked records that are still eligible, as {"items":[…],"next_cursor":null}. The purpose is pinned to the steward's own review purpose; optional filters are subject_kind + subject_id (exact pair), sensitivity and limit (1–100). Sensitivity is default-deny: an omitted sensitivity returns only normal records, sensitivity=sensitive is required for sensitive ones, and any other value answers 400. Approving a candidate (POST /governance/memory-review/candidates/:id/approve) accepts an optional {"sensitivity":"normal"|"sensitive"} (default normal) that the published record keeps; a corrected record never downgrades from sensitive to normal. A member's GET answers the empty collection shape 200 {"items":[],"next_cursor":null}. Every mark and read is attributed in the memory audit trail.

Correct, restrict and forget recalled records

Stewards (admins and managers) apply the memory lifecycle controls to recalled experience: POST /governance/memory-review/records/:recordId/correct supersedes a record with a corrected successor (the body is the lifecycle correction schema — provenance, validity, retention, metadata, content), .../restrict with an empty body takes a record out of recall without deleting it, .../erase forgets it as a tombstone, .../holds places a legal hold ({"reason_code","authority_ref"}), and POST /governance/memory-review/holds/:holdId/release lifts it. POST /governance/memory-review/retention-sweep with {"as_of"} forgets exactly the retention-expired records and skips actively held ones.

Every control takes effect on the next read: a restricted, superseded or erased record never appears in the eligible read again. A correction keeps the record’s subject, purpose and sensitivity and never carries the reviewed-eligibility mark — a steward must mark the successor before it can be recalled. Erasing a held record answers 409 {"error":"active_legal_hold"} naming the hold and erases nothing until the hold is released. A missing or foreign record answers 404; an illegal transition (restricting a restricted record, erasing twice, releasing a released hold, a future as_of) answers 409 as the terminal result. A representative (member role) POST answers the standard 403 refusal and changes nothing. Every control is attributed in the memory audit trail.

GET /governance/platform/skills/:id/quality?orgId=…&version=…&from=…&to=… restricts both aggregates and sealed reports to the selected organisation, skill version and period. Version filtering happens before the 2,000-report limit, so newer versions cannot crowd historical comparison evidence out. Truncation is reported explicitly; currency amounts remain separate and CSV costs retain exact nanos.

Assistant upload routing

An assistant upload may emit a stage_directive event with kind: "upload_result" and fields.uploadReport.rows. Each row contains artifactId, filename, category, workflow, and optional libraryFileId, issue, reviewPath. Ordinary files open My Docs; financial and customer-list subsets keep separate review actions. A saved original does not mean a customer or sales record was committed. Unreadable or uncertain files remain saved with explicit notices. Clients must validate internal review paths and keep results scoped to the current identity. The older triage card may include library_file_id to reuse an uploaded original instead of uploading twice.

Assistant attachments stored only in My Docs

For files above the CRM intake size limit, POST /agent/chat accepts an artifacts entry whose id is the native libf_ identifier followed by 64 lowercase hexadecimal characters, with the same library_file_id and a filename. Ordinary intake references still use art_ IDs. The server verifies current access and personal ownership through the Library before acknowledging storage. Default upload routing returns My Docs with a not-processed notice; it does not read a nonexistent intake artifact or infer a customer from the filename. Missing, refused, or non-owned originals are not reported as saved.

Assistant conversation history

A rep’s Assistant threads are stored by the agent pillar, not the browser. GET /agent/assistant/threads answers {"items":[…]} with the caller’s own threads, newest touched first and capped at twenty, each carrying id, title, archivedAt and its messages. GET /agent/assistant/threads/:id returns one thread as {"item":…}. PUT /agent/assistant/threads/:id upserts it from {"title","messages","archivedAt","lastTaskStatus","context"} — a body carrying messages replaces the whole transcript, a body carrying only title renames an existing thread, and a rename of a thread the caller does not own answers 404 rather than creating one. The optional archivedAt (epoch milliseconds) is when the client considers the conversation last touched and becomes the thread’s stored recency, so uploaded history keeps its original order instead of arriving all at once; it is omitted for a live turn, and a value ahead of the server clock is pulled back rather than refused. DELETE /agent/assistant/threads/:id removes the thread and its transcript; the next read no longer lists it anywhere. A delete the browser decided but could not deliver — offline, or a 5xx — is re-issued before the next read and the thread stays hidden there in the meantime, so a failed delete is retried rather than undone.

Organisation and user come from the verified session on every call and can never be passed in. Another member of the same organisation, and any member of another organisation, read 200 {"items":[]} from the collection and an indistinguishable 404 {"error":"not_found"} from a single thread, a rename, or a delete. Unauthenticated collection reads are the same honest empty list; unauthenticated writes answer 401. Conversations created before this capability shipped are uploaded once from the browser that holds them, the first time that browser opens the Assistant, and are never deleted from it in the process. The MCP and CLI adapters are not offered for history — it is the signed-in user’s own conversation surface, not a governed business action.

Conversation event log

Each conversation also has a server-side event log, written while a turn runs. A chat turn names its conversation with the optional thread_id in the POST /agent/chat body (1–64 characters of letters, digits, _ and -; any other value answers 400; it may accompany operational_kinds, and that turn is logged and owner-checked the same way). The log then records one user_message event ({"text","context"}) before the reply starts, followed by one event per stream frame through message_stop, each with a sequence number. Numbers are unique and strictly increasing within a conversation, but not contiguous across turns: each turn reserves a block of numbers when it opens, and the block’s unused numbers are gaps. Frames are sent as soon as they are numbered and written to the log right after, so the events of a turn still in progress may appear a moment after the browser saw them; the after cursor reads by number and is unaffected by gaps. When turns of one conversation overlap, the events read returns only the ordered prefix that can no longer change: events of a later overlapping turn appear once the earlier turn finishes (or after 30 minutes without activity), and head stops at that prefix. A turn that ends without message_stop (a failure, a disconnect) is closed when its stream ends; once a turn is closed or has gone 30 minutes without activity it can add no further events (closing it writes its one terminal event, numbered above everything before it; see Resume and Stop), so nothing can appear later below a head already returned. Every frame sent to the client carries its own event’s number as seq, and message_start also carries thread_id. A request without thread_id streams exactly as before and writes no log. Once the turn’s log is open every frame carries its seq; if writing a frame to the log fails, the frame has still been sent, and the log simply lacks that event. No frame waits on the log: the next block of numbers is reserved in the background, and if it is not ready when the current one runs out, frames are sent without seq until it is. If the log cannot be reached at the start of a turn, or does not answer within 2 seconds, the turn answers 503 before any model call, unless the caller’s own saved transcript proves they own the conversation; that turn then answers without seq.

GET /agent/assistant/threads/:id/events?after=<n> answers {"items":[…],"head":<n>}: the events after sequence n (default 0), oldest first, at most 500 per page, each with seq, kind, data, run_id and at (ISO time), and head, the safe cursor: the highest sequence number up to which this read is complete. To continue, repeat with after set to head. When a page is full (500 events), head is the last event on that page, so the next read starts right after it. Only the conversation’s owner reads it: another member of the same organisation, any member of another organisation, or a signed-out caller reads 404 {"error":"not_found"}, and a chat turn sent with another member’s thread_id answers that same 404 before any model call; if the conversation’s owner cannot be checked the turn answers 503, also before any model call. Like the transcript, a log whose answers drew on private mailbox content reads only while the caller holds a current grant on every source mailbox; otherwise it answers the same 404. That check covers every turn the conversation ever recorded that drew on mailbox content, and every turn the saved transcript has named since the log was opened (rewriting the transcript never narrows it), not only the events on the page returned, so reading with after past such a turn does not lift it. A conversation id in these paths is decoded once and every check uses that one id, so %2D and - name the same conversation; it is decoded exactly once, so a stored id containing % is requested as %25; an id that contains /, a control character or a Unicode format character (such as a zero-width or bidirectional mark) after decoding answers 404. The first turn or transcript save of a conversation claims it for its sender, once and for good: a conversation another member has already saved cannot be logged by anyone else, even before its log exists, and PUT /agent/assistant/threads/:id on a conversation whose log another member opened answers the same 404 and saves nothing (503, also saving nothing, if the claim cannot be checked). Only a save that carries messages and would be accepted claims the conversation; a title-only, malformed or over-limit save claims nothing. A save answers 503 {"error":"unavailable"} and leaves the stored transcript unchanged when the provenance of the runs it names cannot be read or when the transcript write itself fails. After its owner deletes a conversation, a turn the owner still sends with its thread_id answers normally but is not logged (no seq), and its events read stays 404. DELETE /agent/assistant/threads/:id also deletes the log, first: if the log cannot be deleted the delete answers 503 and leaves the conversation in place so the browser retries it, and a deleted log is never recreated by a turn that is still running. A saved conversation that never had a log is claimed for its owner and its log deleted all the same, so that id is never logged again. A conversation that only ever had a log (its transcript was never saved) is deleted with 200 {"item":{"id","deleted":true}}. Repeating a completed delete answers 404. Pruning past the cap removes only the saved transcript; the conversation’s log stays readable to its owner until the owner deletes the conversation, and its mailbox provenance keeps applying. A delete answers 503 {"error":"unavailable"} when the provenance of a never-logged conversation cannot be read or when deleting the transcript fails; a retry completes it. The events read itself answers 503 {"error":"unavailable"} when the log or its mailbox scope cannot be read. A conversation keeps its newest 5,000 events; past that the oldest whole finished turns are dropped, even when turns interleave, a turn still running is never dropped, and numbers are never reused.

Resume and Stop

A logged turn keeps running after the browser disconnects: its frames are still written to the log through message_stop, within the platform’s budget for work after the response ends (about 30 seconds). GET /agent/assistant/threads/:id/events?after=<n>&wait=1 holds the request for up to 25 seconds while the read would return no event after n and head would not move past it, then answers as above (with empty items when nothing arrived). The read also returns open_runs, [{"run_id","idle_ms"}]: every run still open and how many milliseconds ago it last logged an event or was touched by its turn’s heartbeat, by the server’s clock. A client following a run treats it as interrupted only when it is open and idle for more than 45 seconds; a run working silently is followed until its terminal event. A reconnecting client starts from the last seq it received and then always continues from the head each read returns, never from the newest event it saw: events of a later overlapping turn appear only once the earlier turn closes, and numbers skipped by a turn’s block are never filled in. A logged run that ends without message_stop is closed with exactly one terminal event, numbered above every seq the turn may already have sent: interrupted, or message_stop {"stop_reason":"cancelled"} for a stopped run. A run left unfinished and silent for 45 seconds is closed this way when the conversation’s next turn starts; a turn that is still working but streams nothing (a long model call) touches its run every 15 seconds, so only a run whose turn has really stopped is closed. A request without thread_id still ends when its connection closes.

A chat turn may also carry an optional client_turn_id in the POST /agent/chat body: the client’s own name for the turn, chosen before the request is sent (1–64 characters of letters, digits, _ and -; it requires thread_id, and any other value answers 400). It is recorded in the turn’s user_message event as {"text","context","client_turn_id"}, so a client that never received message_start — the connection dropped first, a Stop was pressed during setup, or a page was closed and reopened — finds the run by reading the events from the start for the user_message carrying its client_turn_id, then follows or stops that event’s run_id. The interrupted event says why the run was closed: {"reason":"error"} after the turn failed, {"reason":"stale"} when it went silent (closed by the next turn, or after 30 minutes), and {"reason":"ended"} otherwise.

POST /agent/assistant/threads/:id/runs/:run_id/stop stops one run explicitly and answers 200 {"item":{"run_id","stop_requested":true}} once the stop is recorded. The run ends at its next frame, or before its next model call, with message_stop {"stop_reason":"cancelled"}: a model call already in progress completes and is charged, no further model call starts, and nothing is logged after that frame. For every provider attempt of a model call — the first one and every fallback attempt on another model route — the turn asks the log, within 2 seconds, whether it was stopped. The question is sent as the attempt’s credit reservation starts and its answer is awaited before the provider request, so it runs alongside the reservation and adds no wait of its own unless it is slower; a Stop recorded before the question is asked is always seen, and that provider request is never sent; its reserved credit is released and the attempt is recorded in usage with the failure class cancelled. A stopped run, an unknown run or a conversation deleted while the run is open ends the turn. A log that cannot be reached in time does not block the turn (as everywhere else, an unreachable log never blocks the owner), and the turn asks no more after the first such failure; the recorded Stop still reaches the turn with the log’s answer to its next batch of frames, so it is delayed by at most one batch. Policy and procedure questions (operational_kinds) are stopped the same way before either of their two model calls. Stopping a run that has already ended also answers 200. Like the events read, the stop is owner-only: another member’s conversation, an unknown run or a signed-out caller reads 404 {"error":"not_found"}, and a log that cannot be reached answers 503 {"error":"unavailable"}.

Conversation memory

When a turn carries thread_id and the conversation’s log already holds earlier turns, the Assistant’s memory of the conversation is built from that log, and every message before the last one in messages is ignored: send only the new message (the user’s text, or the front-end tool’s tool_result that continues the turn). The last message is still the turn’s instruction. A request without thread_id, and the first turn of a conversation whose log is still empty, build the memory from the messages they carry, exactly as before. A turn that continues with a tool_result records it in its user_message event as {"text":"","tool_result":{"tool_use_id","content"}}, and a question sent with operational_kinds is recorded with "operational":true and its "operational_kinds"; operational answers and error lines are not part of the memory. If the log cannot be read, if it holds nothing but operational answers or turns an edit replaced (and no earlier messages its first turn recorded), or if the conversation drew on private mailbox content and the caller no longer holds a current grant on every source mailbox, the turn is answered with no earlier memory: the earlier messages are never used in its place. Memory built from a conversation that drew on private mailbox content records those source mailboxes on the new run before the model sees it, so the run’s trace is restricted exactly as a mailbox read’s is; if they cannot be recorded, the memory is withheld the same way.

Two optional body fields, accepted only with thread_id, keep the memory on the branch the user sees. Each names a turn: a logged reply’s run_id, or an earlier turn "prior:<i>" (below). A turn whose reply failed before it opened is named by the run its error frame carries in "run_id", so an edit after it keeps its question. Edit and resend sends "supersedes_after_run_id": the last turn the edited conversation keeps, or null when the first prompt was edited; every turn after it is left out of this and every later turn’s memory. Regenerate sends "regenerates_run_id": the turn whose reply is being replaced; the turn is answered from the memory before that turn, and later turns see the new reply without the question repeated (the turns after the replaced one stay). Both are recorded in the turn’s user_message event as "supersedes_after_run" and "regenerates_run"; they can only select or cut what the memory holds, never add to it. A value that is neither 1–160 characters of [A-Za-z0-9_-] nor prior:<i>, or either field without thread_id, answers 400. A prior:<i> the conversation never recorded is not refused: it is resolved by turn order — Edit and resend keeps the recorded earlier turns up to it and no logged turn, and Regenerate answers from the recorded earlier turns before it; a turn left out of the log (below) is older than every recorded one, so nothing before it is kept. A conversation saved before its turns were logged sends its whole transcript once, before the new message, with its first logged turn: the earlier messages are recorded with that turn as its earlier turns ("prior_turns"), each with the id "prior:<i>" (i counts from 0; a turn is one user message — a question, or a front-end tool’s tool_result — and the replies that answer it), and they remain in the memory once later turns send only their new message. Only the conversation’s first logged turn records them, a question sent with operational_kinds included; the earlier messages any later turn sends are neither recorded nor used. Each message is recorded as sent, and the memory shortens a long one when it is built, exactly as from the request; when the turns would not fit in one log event the oldest whole turns are left out first ("prior_offset" counts their messages; the kept turns keep their ids). That turn’s message_start frame carries "prior_from": the i of the first earlier turn recorded (the number of earlier turns sent, when none fit), so a client gives turn ids only to the turns the log holds. The 20-message and 24,000-character bounds apply to the memory after the branch is chosen, never to what is recorded. An Edit and resend or a Regenerate inside the earlier messages names their turn; when it is the conversation’s first logged turn it sends the whole transcript with it. A turn’s own message is recorded whole while one log event holds it, so a copy rebuilt from the log shows exactly what was sent; a longer one keeps its first 8,000 characters and its end ("text_tail", "content_tail"), so the memory shortens it exactly as before. When its whole text is at most 1 MiB of part data, that event also announces "text_parts" (or "content_parts" inside "tool_result") as {"count", "chars"}, and the whole text follows as count "user_text_part" events of the same run_id, each {"index", "text"}, before the reply’s first frame. These events appear only in this events read, never on the chat stream. Rebuild the text only when every "index" from 0 to count − 1 is present exactly once, the joined text has "chars" characters, and it starts with "text" and ends with "text_tail" ("content" and "content_tail" for a tool result); otherwise show the start and end with a visible mark that the middle is missing. The memory is the same either way. When the message also carries framing meant for the model on this turn only, send the request as the user sees it in the optional "display_text" (a non-empty string, accepted only with thread_id and never with operational_kinds; otherwise 400): the log records that text, so later turns remember the request as shown and never the framing, while this turn is still answered from the message itself.

With the earlier messages a conversation saved before its turns were logged sends once, an optional "prior_replies" array (only with thread_id) carries what the client showed on those replies, so a copy of the conversation rebuilt from its log keeps them: each entry is {"index","run_id?","memory?"}, where index is the reply’s position among the messages before the last, run_id is the run that produced it (1–160 characters of [A-Za-z0-9_-]), and memory is its memory card {"content","status"} (1–500 characters after control-stripping; status proposed or saved). At most 500 entries; a body whose prior_replies is not an array, has more entries, or comes without thread_id answers 400, and an entry that names no reply among the earlier messages, or carries nothing valid, is dropped. They are recorded only with the earlier turns the first logged turn records, as each turn’s "replies" [{"at","run_id?","memory?"}] (at is the reply’s position within that turn’s messages); they are data for the client and are never part of the memory.

Optional "prior_operational" on POST /agent/chat carries {"after","role","kinds","question"} display markers with thread_id. It supports both normal and operational first turns, including a policy-only branch with no earlier model messages (after: -1). At most 500 entries: after is an integer from -1 through the last earlier message index; role is user or assistant; kinds is policy, procedure or both; question is control-flattened and trimmed to 2–8000 characters. A wrong container, excessive count or missing thread answers 400; malformed entries are dropped. Only the first logged user message retains these markers. Earlier model turns have budget priority; oldest markers are omitted first. Older stored markers without IDs use their original stored array positions; invalid rows are skipped without renumbering other markers. Marker text never enters model history. These markers are never part of the memory, and are kept only by the log’s first recorded turn. Stored markers receive server IDs prior_op_N before trimming. An opted-in request receives prior_operational_ids with exactly the retained IDs on its first message_start, possibly an empty array; multipart opening messages can make that frame sequence greater than 2. Later turns and legacy requests without the field receive no acknowledgement. Assign only acknowledged IDs. A recheck_of may name a stored marker in this same conversation; an edit can anchor to that marker. If its first-upload anchor was omitted, the logged supersedes_after_run is canonicalized to the preceding retained prior:<i> or null, without copying marker text into model context.

A turn that looks many things up may also log compaction events. When the messages a turn has added since it began (its tool calls and results) would pass 48,000 characters, the Assistant shortens that turn’s oldest tool results first, before its next model call, until the turn fits again: each shortened result keeps its tool name, its outcome, its opening text and up to 40 identifiers it found (customer, engagement, document and evidence ids), so the Assistant can look a record up again when it needs more. The instructions, the tool list and the conversation before the turn are never shortened. Each compaction event carries {"hop","compacted","charsBefore","charsAfter"}: the model call it preceded, the results shortened at that point ([{"id","name","before","after"}], with id the tool call id its tool_step frames carry), and the characters of the messages the turn has added, as that model call would have sent them before this shortening (results shortened at an earlier point already counted in their short form) and as it sends them after. A result once shortened stays shortened for the rest of the turn. The final answer is still checked against the full results. A turn that stays within the budget logs no compaction event; clients that do not know the event ignore it.

A policy or procedure re-check (a turn with operational_kinds) may carry an optional "recheck_of": the turn whose answer it refreshes — that answer’s run_id, or an earlier turn "prior:<i>". It requires thread_id and operational_kinds; a value of neither shape, or the field without them, answers 400. Before the turn is logged, the conversation’s log is read: the link is recorded in the turn’s user_message event as "recheck_of" only when it names a turn of the same conversation (a logged run, or an earlier turn its first logged turn recorded, or this turn records); otherwise, or when the log cannot be read, the re-check is recorded as a question of its own. A client replaying the log applies a linked re-check to the answer it names instead of showing another turn.

Per-user assistant memories

Each signed-in user’s confirmed assistant memories are strictly private to that user. GET /agent/memories answers {"items":[…]} with the caller’s own active memories, oldest first, each carrying id, content (1–500 characters), locale, origin, status, createdAt, createdVia, updatedAt and retiredAt. POST /agent/memories with {"content","locale?","origin?","created_via?"} creates one: origin is user_statement or assistant_proposal (default user_statement), created_via is chat or panel (default panel). The 51st active memory answers 409 {"error":"memory_limit_reached"}; content outside 1–500 characters after trimming and control-stripping answers 400 {"error":"memory_invalid"}. PATCH /agent/memories/:id with {"content"} applies the same bound on edit; POST /agent/memories/:id/retire retires a memory and is idempotent.

Organisation and user come from the verified session on every call and can never be passed in. Another member of the same organisation — including admins and platform superadmins — and any member of another organisation read 200 {"items":[]} from the collection and an indistinguishable 404 {"error":"not_found"} from another person’s memory. Unauthenticated collection reads are the same honest empty list; unauthenticated writes answer 401. Every mutation appends a content-free audit event (user, action, memory id, timestamp): what changed is recorded, never what it said. A user’s active memories ride their own chat turns as a bounded “About the current user” section; a failed memory read is skipped and never breaks the turn.

A memory card the Assistant proposes on a reply, and a question it asks, are decided once for the whole conversation. POST /agent/assistant/threads/:id/decisions records one decision on a reply, named by its turn (the reply’s run_id, or prior:<i> for an earlier turn of a conversation saved before its turns were logged): {"turn","kind":"memory","outcome":"saved","content"} saves the card’s memory, {"turn","kind":"memory","outcome":"dismissed"} dismisses the card and {"turn","kind":"ask","outcome":"skipped"} skips the question. It answers 200 {"item":{"turn","memory?":{"outcome","memory_id?"},"ask?":{"outcome"}}} with the reply’s decisions after the call, plus "memory" (the memory item) for a save. A card saves at most one memory: a second save of the same card — from another device, at the same moment, or from an old copy — answers the memory the first save created, even one retired since, and creates nothing. A saved memory outranks a dismissal, so dismissing a card that was already saved answers saved; otherwise the first decision stands. A save keeps the 50-memory limit and the content bound (409 {"error":"memory_limit_reached"}, 400 {"error":"memory_invalid"}). GET /agent/assistant/threads/:id/decisions answers 200 {"items":[…]}, one item per decided reply. Like the events read, decisions are owner-only: another member’s conversation, an unknown one or a signed-out caller reads 200 {"items":[]} and a write answers 404 {"error":"not_found"}; a write to a conversation with no logged turns — including any conversation id addressed from another organisation, which only ever reaches that organisation’s own empty record — answers 404 {"error":"not_logged"}, and the card is then saved with POST /agent/memories; a log that cannot be reached answers 503 {"error":"unavailable"}. Deleting the conversation removes its decisions.

Knowledge retrieval: meaning fallback and conflicting figures

GET /knowledge/library/documents?q=… matches document names and stored text literally. When that keyword match returns nothing, the route now retrieves the same organisation-scoped, permission-filtered document set again through the governed semantic index and answers 200 {"items":[…],"match":"semantic"}. The match field rides only on a semantic answer — a keyword answer carries no match at all — so a caller is never told that words were found when they were not. The fallback obeys the request’s own folder_id, ext and updated_within narrowing, ranks by cosine similarity over the passage embeddings and offers nothing below its similarity floor; each returned document carries its matching passage as an unhighlighted excerpt. Nothing close enough, no embedding binding, or a failed embedding call all leave the honest 200 {"items":[]} in place — the fallback never turns an answerable search into an error. No new index technology and no CRM data: the corpus is the Library’s own ready documents.

POST /knowledge/sandbox/ask gains an additive conflict field. When the passages an answer draws on state different figures for the fact that was asked — 30, 45 and 60 days for one quotation validity period — the response carries {"unit":"day","sources":[{"file_id","file_name","passage_index","figure","value","date"}]}, every disagreeing document is cited and named in sources, so the answer shows the figures side by side instead of one of them alone — contradicting documents corroborate nothing. figure is the quantity exactly as that document writes it (thirty days, 45 days, 六十天) and date is that document’s own updated date, or null when it has none. Detection is numeric and lexical inside the existing answer composition — no extra model call — and a figure counts only when the words leading to it in its own sentence match the question, so an unrelated number in the same document raises nothing. Answers whose sources agree omit conflict entirely and are unchanged. The signed-in application shows the disagreement above the quoted answer in English and 中文.

Library passage search: hybrid mode and locators

GET /knowledge/library/search?q=…&limit=… gains an optional mode parameter: token (the default, also what an absent mode means) or hybrid. mode=hybrid ranks passages with the same fused recall Ask Knowledge uses: the keyword ranking’s top 8 and the semantic ranking’s top 8 above the 0.40 similarity floor are merged by reciprocal-rank fusion (k = 60) into at most 16 passages, then capped at limit (1–20, default 8). Each item keeps file_id, filename, ext, passage_index and snippet; score becomes the fused score (a small fraction, higher is better), and an additive locator names where the passage sits: {"page"?, "sheet"?, "range"?, "part"?, "line"?, "kind"} — the PDF page, the spreadsheet sheet and cell range (for example Terms / A2:C2), the office document part, or the text line, whichever the passage stored, plus its kind (text when none was stored).

In hybrid mode the keyword side also matches part numbers whole: a query token such as MC-3200-T (letters, digits and -, _ or /, with at least one letter and one digit and four characters) finds MC3200T, mc_3200_t and the full-width spelling, in addition to the ordinary word matches. If the embedding call fails, takes longer than 2 seconds, or no embedding binding is configured, hybrid mode answers 200 with the keyword ranking alone — not the 503 embeddings_unavailable that semantic=true returns. Hybrid admits exactly the passages the other modes do: ready documents of the caller’s organisation that the caller may see.

Nothing changes for callers that do not ask: mode absent, mode=token and semantic=true|false return exactly the items, order and fields they returned before, with no locator. mode and semantic together, or a mode other than token or hybrid, is 400 invalid_request. The assistant’s library search and the ekb.library.search action (MCP ekb_library_search) request mode=hybrid.

Library passage read

A search snippet is only the start of a passage; the clause you need is often further down. GET /knowledge/library/files/:id/passages reads the full text of what a search found. Use the file_id and passage_index from a /knowledge/library/search item. Send exactly one of the two query forms:

  • ?around=<n>&radius=1 (radius may be omitted; 1 is the only value) returns passage n in full plus at most one neighbour on each side, in document order, at most 6,000 characters in total.
  • ?all=1 returns every passage of the file in order when its extracted text is at most 12,000 characters. A longer file answers 200 with status: "too_large", the passage_count and an empty passages list — no text. Read its passages with around instead.

Response: {file_id, filename, ext, status: "ok"|"too_large", passage_count, passages: [{passage_index, text, locator}]}. Each locator is the one the hybrid search returns for that passage: {"page"?, "sheet"?, "range"?, "part"?, "line"?, "kind"}. Passage indexes count the same passages the search counts.

The read uses the same visibility as the search: ready documents of the caller’s organisation that the caller may see. A file in another organisation, another member’s personal file, a file that is not ready, and a passage index out of range all answer the same 404 not_found body, with no text. A missing or conflicting query form, an unknown parameter, a non-integer around or a radius other than 1 is 400 invalid_request. The route is GET only. The action ekb.library.passages.read (POST /assistant/v1/actions/ekb.library.passages.read with {"file_id", "passage_index"?}; no passage_index reads the whole file), the MCP tool ekb_library_passages_read and the CLI command atlas library read <file> [--passage <n>] return the same content under ekb:read; a missing passage is the action’s 404 not_found.

In the assistant, read_library_passage and read_library_document make the same reads. The assistant makes at most two library searches in one reply: a third returns search_budget_reached, and the reply then says the documents do not state the answer.

Sandbox answers: hybrid recall and written answers

POST /knowledge/sandbox/ask keeps its request exactly as it was and changes what it answers. The Sandbox previously scored an ask and never generated one: the answer WAS the best keyword-matching passage, quoted verbatim, with a confidence percentage beside it. That design is now deliberately reversed. A keyword scorer answered “Summarise our pricing policy” with a warranty document at confidence 66, and answered “价格是多少” not at all although the corpus states 报价有效期三十天 — Chinese bigrams of 价格 share nothing with 报价, which no deterministic tokenizer fixes.

confidence is removed from the response, and nothing numeric replaces it. It was arithmetic over keyword hits, never a probability, and stating it beside a wrong answer was the failure the revamp exists to remove. Each citation keeps its own pct, which describes that one passage’s word overlap with the question and makes no claim about the answer. The new answer_kind states provenance instead: "synthesized" when the model wrote the answer from cited passages, "quoted" when it is the verbatim top passage, and "none" when there is no answer at all. The rating snapshot a Sandbox answer is fed back through — question, answer, scope, sources — is unchanged.

Recall is hybrid. The shared keyword ranking and the Library’s stored multilingual passage embeddings are fused by Reciprocal Rank Fusion (k = 60, at most eight passages), so a passage either signal found is reachable and a passage both found leads. A vector is admitted only when its document is in the scope the caller’s own organisation-scoped resolution returned; an id in the request body never admits one, and a document excluded as not found, not ready or without usable passages is unreachable by meaning too. No embedding binding, a failed embedding call and a document with no stored vectors are each a normal ask answered from keywords alone, never an error.

Synthesis is one governed Workers AI call at temperature 0 with a JSON schema response, through the auto-provisioned gateway with per-call organisation, run and skill metadata, caching off and gateway logging off. The model receives the question and the passage text with numbered positions and nothing else — never a file id, a file name or an organisation — and returns the positions it used, so every citation, name and snippet is resolved on the server against this ask’s own retrieval. A figure the answer states that appears in none of the passages it cited is refused, where those passages write figures as digits at all.

A decline is not a degraded answer. When the model reports that the passages do not contain what was asked, the response is {"phase":"notFound","answer_kind":"none","answer":""} with closest: up to three of the best passages retrieval found, each {"file_id","file_name","passage_index","snippet"} with the snippet bounded at 200 characters and no score of any kind. The keyword quote is never served in its place — serving it is exactly the warranty-document answer. closest rides on notFound only, may be [], and is absent from done and scopeEmpty.

Every rung of the degradation is a 200, never a 500: the model answers → done, "synthesized"; the model cannot be reached, threw, or returned an envelope that will not decode → done, "quoted", the deterministic passage, or notFound with closest when keywords matched nothing either; the model declined → notFound, "none", with closest; nothing recalled at all → notFound, "none", closest: []. A refused usage attribution degrades the accounting and never the answer. Logs from this path carry ids and error classes only: the question and the passage text are never written to them.

Two behaviours are unchanged. An ask carrying non-empty operational_kinds stays deterministic and answers "quoted", because a governed policy or procedure release is an authorised statement to be cited exactly, not paraphrased; and that ask remains signed-in-session only, refused to delegated tokens. An ask is still an answer to check, not a compliance judgement. While the Enterprise Memory switch in Platform → Feature visibility sits at Developers only for the caller’s organisation, the ask answers 403 {"service":"atlas-knowledge","error":"enterprise_memory_disabled"} instead; document-only asks are unaffected, and an organisation lifted to Preview by a per-organisation override asks as usual.

Unreadable workbooks in a mixed scope. When a scope mixes workbooks and documents and the spreadsheet engine cannot read a workbook — its sheet layout is unsupported, or its index will not load — the workbook is listed in excluded with reason: "spreadsheet_unreadable" and a detail string carrying the engine’s own refusal. The question is then answered from the documents instead of ending in the refusal, but only when it is known not to be a calculation: the question was classified as being about the documents, the engine’s plan was a lookup, a filter or “not a spreadsheet question” rather than a sum, count, average, minimum, maximum or comparison, or the engine reached no concrete plan (for example ambiguous column names) and the question carries no calculation word. A refusal with no single target sheet lists every workbook it was asked over. Otherwise the spreadsheet refusal stands — a calculation keeps it, so do the file and 16,000-cell budgets and the select-fewer refusals, and so does a calculation when none of the workbooks could be loaded — and a scope of workbooks only is unchanged.

Document questions skip the spreadsheet check. In a mixed scope, a question with no calculation word (total, sum, average, count, how many, maximum, minimum, compare, more than, 合计, 总和, 平均, 多少个, 最高, 最低, 比较 and the like) that names no workbook in scope — by file name, or by a word such as “spreadsheet” or 表格 — goes straight to the documents beside the workbooks, without the model call that decides whether it is a spreadsheet question. When that call is still needed, document recall runs alongside it rather than after it. Only workbooks the caller may see are matched by name. excluded[].reason is therefore one of not_found, not_ready, no_passages or spreadsheet_unreadable; detail is present on the last only.

A part’s row is evidence. A question with no calculation word keeps the workbooks’ rows in document recall, so a figure a row states (a part’s MOQ in a price list) is answered and cited even when the spreadsheet engine cannot read that workbook; a calculation never reads rows as prose. A workbook whose row the answer cites is not listed under excluded. A citation of a table row shows the table’s heading row with the row: a workbook row carries each heading beside its value, and its citation carries sheet and range; a PDF table row is preceded by Table header: …. Such a snippet is bounded at 400 characters rather than 200.

Every response from POST /knowledge/sandbox/ask — a written, spreadsheet or operational answer, a miss, or a refusal — carries a Server-Timing header with six whole-millisecond stages: scope, recall, model, post, total and route. route is the mixed-scope routing decision (the workbook lookup plus the document shortcut or the spreadsheet check); recall that ran alongside it counts in recall only for the wait left afterwards.

Organisation Skills page

GET /governance/skills is the Skills page’s own catalog: every active catalog skill for the caller’s organisation, with its bilingual name, the catalog role ids and the boolean appliesToAssistant derived from its applicability.surfaces, the same 30-day attempts/succeeded/failed/refused/conversationsCompleted/conversationsFailed/liked/disliked usage the existing usage summary already reports, and this organisation’s own enabled state and setting revision. Owner or admin only; every other signed-in caller reads the honest 200 {"items":[]} rather than a refusal. POST /governance/skills/:id/setting takes {"enabled","revision"} and stores the organisation’s decision with the acting admin and the time, guarded by the same optimistic revision as every other platform head: a stale write changes nothing and answers 409 stale_revision, and an id that is neither a catalog skill nor one of the caller organisation’s own authored skills answers 404 unknown_skill; the same switch and guard govern both. Disabling a skill grants or removes no authority — a hire’s allowed action classes and permission level are unchanged either way — it only stops the skill from being offered to any new resolution for that organisation; a run already using a pinned version finishes unaffected. GET /governance/internal/skills/resolve gains two optional query parameters, additive and backward compatible: absent org, resolution behaves exactly as before. A caller that names a version AND sets pinned=1 asserts, server-side, that the version came from state IT holds itself (a stored extraction result, a receipt for a skill this same request already resolved) rather than from a browser-supplied header or query parameter — a distinction that matters because a skill’s version and digest are a deterministic, non-secret catalog identity, so a client that once saw them could otherwise replay them indefinitely to keep resolving a skill its organisation has since disabled. Only an unversioned call, or a versioned one with no pinned=1, can be refused as 404 skill_disabled_for_org; a genuinely vouched-for pinned continuation is never refused for this reason. Every read and write here is scoped to the caller’s own organisation in the store itself, so one organisation’s setting for a skill can never be read, listed or changed from another organisation’s session.

GET /governance/internal/skills/index is the internal, signed-only read an Assistant turn takes its skill library from. Both query parameters are required, org and surface (the Assistant asks for surface=assistant); a call missing either answers 400 invalid_request, and a call without the internal header or signature answers 403 forbidden. It returns 200 {"items":[...]} with one item per active catalog skill whose applicability names that surface and that this organisation has not switched off, ordered by skill id, each carrying skillId, version, digest, the bilingual name and description, and the discovery terms and tools. The Assistant lists those skills by id, name and one line in its prompt, and its load_skill tool returns one listed skill’s full text: every load resolves the skill again for the caller’s organisation, so a skill switched off mid-conversation, or an id replayed from an earlier turn, is refused as skill_disabled_for_org with no text, no pin and no receipt. A successful load pins id@version for the rest of the reply, records one skill-run receipt and names the skill in the reply’s “Skills used” line; at most three loads run per reply. The index grants no authority: it is discovery only, and loading a skill changes no permission.

An owner or admin can also author the organisation’s own text skills. POST /governance/skills takes {"name","description","instructions","keywords"}: each text is a string or {"en","zh-CN"} (a blank language is filled from the other), instructions holds one instruction per line, and keywords is an optional list of strings. It answers 201 {"skill"} with draft 1.0.0, or the next patch version (1.0.1, …) when the skill already exists; the id is derived from the English name (“Quote etiquette” → quote_etiquette) and the content digest is computed by the server. The surface is fixed by the server to the Assistant: surfaces is not accepted. A save is refused, and nothing is stored, with 400 forbidden_field when the body carries a script, code, command, connector, permission, scope, secret or token field at any depth; 400 forbidden_content when the text, already within the caps, looks like a script or active HTML, code or a secret, or when an instruction line falls under one of two line rules: a line that opens (after an optional bullet, quote or prompt, in any letter case) with a token, secret, password, API key, permission, scope, connector or credential key followed by : or =, whatever follows; or a line that opens with a known command word (such as ls, echo, curl, git, npm, powershell, cmd or a Get-/Set-/Invoke- command) followed by anything. Both rules refuse such a line even when it was meant as prose (“Scope: quotations only”, “Echo the buyer’s words”); the response then carries line, the 1-based instruction line to rephrase; a named cap refusal (name_too_long over 120 characters, description_too_long over 1,200, instruction_too_long over 300 per line, too_many_instructions over 20 lines); 409 id_reserved when the derived id is a catalog skill id; 409 skill_limit_reached when the save would start a new skill while the organisation already has 50 skills with an active version or a pending draft; and 409 version_limit_reached when the skill’s patch version has reached 9999. POST /governance/skills/:id/versions/:version/activate publishes a draft as the organisation’s only active version (the previous active version is retired), and POST /governance/skills/:id/versions/:version/retire retires a draft or the active version; both take {"revision"} and refuse with 409 stale_revision, 409 version_retired or 409 version_already_active, or 404 unknown_skill/404 unknown_version. Retiring the active version removes the skill from the Assistant’s index on the next turn; a reply that already loaded it finishes unchanged. Every other member is refused with 403 forbidden. GET /governance/skills items now carry source (catalog or organisation), currentVersion, draftVersion and headRevision; an organisation row also carries authored, the text an edit starts from. When the catalog does not know an id, GET /governance/internal/skills/resolve and GET /governance/internal/skills/index fall through to the calling organisation’s own active skills only, still honour the organisation’s switch, and label them source: "organisation"; a resolution without org never reaches them. One organisation’s skills are never visible to another organisation, nor in the Platform catalog. An organisation skill is guidance text: it grants no permission and executes no code.

A method an admin teaches a hire and publishes is also recorded in the organisation’s Skills library, as an organisation skill version with the same digest and text. GET /governance/skills lists it with source: "taught" and a taught block naming hireId, scope (account for one customer, organisation for the whole organisation) and taughtVersionId, the hire’s own version id; the id is taught_organisation or taught_account_<hireId>, and the version is 1.0.<n> in publish order. Its status is active while the hire uses that version and retired the moment the method is withdrawn, rolled back or invalidated; a withdrawn or superseded row stays on the page, read Retired. Each hire run that applies the method records one skill-run receipt against it, with inputKind taught_method, so its 30-day usage counts like any other skill. The row is read-only: POST /governance/skills/:id/versions/:version/activate and retire answer 409 id_reserved, POST /governance/skills/:id/setting answers 404 unknown_skill, and a new skill whose derived id starts with taught_ is refused with 409 id_reserved, while a skill an admin authored under such an id before taught methods were recorded stays an ordinary authored skill with its own switch and actions; taught methods never count toward the 50-skill limit. The hire keeps reading its method from the AI team exactly as before: the library row is a record and a usage counter, never a second source of instructions, so GET /governance/internal/skills/index never lists it for any surface and GET /governance/internal/skills/resolve answers it only for an exact version with pinned=1, labelled source: "taught". The Agent writes the record through the internal, signed-only PUT /governance/internal/org-skills/taught, bound to its organisation exactly like a skill-run receipt; a mismatched actor binding answers 400 invalid_request, and a version already held for a different taught version, or an id an admin authored, answers 409 version_conflict or 409 id_conflict. Each write carries a sourceRevision that rises with every change to the hire’s method; the highest one received is always recorded, and a write older than it changes no status, so a delayed or repeated write never brings back a withdrawn method or undoes a newer one. The library keeps the method’s exact text, whatever its spacing. When the organisation’s retention period erases a method’s text in the AI team, the same retention run erases the library copy’s text; the row stays Retired with its digest, and no later write activates it again. A record lost after a publish, a withdrawal or an erasure is repaired when an admin opens the Skills page: every method the hires currently use is checked on each visit, and every other recorded method that is still active or still holds text is re-checked in turn, a bounded number per visit, so each one is reached within a bounded number of visits.

Sending-account choice and personal default (#2834)

GET /agent/prepared/:preparedWorkId/sender-projection returns the sending choices for a pending email draft. With no configured email account it returns {"state":"none","from_address":"…"} and the review names the existing Atlas-mailbox fallback. Otherwise it returns {"state":"accounts","accounts":[…],"default_connection_id":…}; each item is a personal or shared account the signed-in user’s email settings can see, with state ready (currently send-authorised) or inactive; an inactive item may carry reason — needs_org_action, disconnected, verification_pending or paused — and withholds its address unless the user holds send authority. Only ready accounts can be chosen or bound. A shared account requires the signed-in user's live named can_send grant: revocation removes the choice and invalidates its default immediately. No credential, host, username or token is exposed. A missing, stale or unauthorised default is returned as null, and the client must require an explicit choice instead of taking the first account.

POST /agent/prepared/:preparedWorkId/sender-default with {"connection_id":"…"} saves that ready account as the signed-in user’s personal default for the current organisation; foreign, inactive and unauthorised ids answer 409 sender_not_available and change nothing. POST /agent/prepared/:preparedWorkId/approve accepts optional {"sender_connection_id":"…"}. The review shows account id and configuration version, From, Reply-To, To, Cc, Bcc, subject, body, and every attachment's immutable id, version, and content_hash. Approval binds those exact fields plus the account's credential version into its digest; changing any of them requires a new review before provider I/O. Without an explicit choice only a currently valid personal default may be used. A paused, failed or draft account never triggers Atlas fallback, and changing the personal default never changes an approval already recorded.

Approved send attempts and explicit retry (#2835)

Every approved mailbox send runs as one durable command inside CRM: it is reserved before any connection is touched, dispatches at most one SMTP attempt, and finalizes exactly once into a closed outcome — refused, failed, provider_accepted or outcome_unknown. Provider acceptance is acceptance, never customer delivery. A timeout or dropped connection after the message may have been accepted records outcome_unknown, and nothing ever re-sends automatically. Concurrent duplicates of the same approval — even with different client keys — collapse onto one command and at most one attempt; repeats return that command's outcome, and the same key naming a different payload answers 409 send_key_conflict before any provider I/O.

The Today review surface reads the attempt state through GET /agent/prepared/:preparedWorkId/send-state (404 when the draft has never attempted a send) and shows the outcome with its closed cause. An uncertain, failed or refused attempt offers an explicit retry through POST /agent/prepared/:preparedWorkId/retry-send with {"confirm_duplicate_risk":true,"prior_command_id":"cmd_…"}: the interface names the risk of a duplicate before the call, the retry records a fresh human approval bound to the current account state, and CRM links the new command to the prior attempt while preserving the prior result. A send still in flight and an already-accepted send are never retryable — the retry answers 409 send_in_flight or 409 send_already_accepted before anything is re-approved.

Persistent scheduled-sync authority outage

When CRM cannot verify the recorded scheduled-sync authority, it retries without exposing a mailbox-permission problem for the first 15 continuous minutes. At 15 minutes, account.sync.cause and the redacted account diagnostics sync health expose the closed cause authority_service_unavailable. This means Atlas cannot verify its authority service; mailbox permissions have not changed. The stored first-unavailable time is cleared before the next valid allow or deny decision. An allow resumes the existing sync path; a deny retains the existing authority_lost behavior. No response includes Auth bodies, credentials, mailbox content, provider text, cursors or lease data.

Explicit customer communication preference

POST /crm/customers/:id/facts accepts the additive typed body {"kind":"contact_preference","channel":"email"|"phone"} with the required x-atlas-idempotency-key header (1–128 characters). The first accepted request returns 201 {item,replayed:false} and stores one confirmed manual fact with category=preference, fact_key=preferred_channel, the selected channel as fact_value, and the caller and confirmation time. It creates no task, timeline entry, last-contact change or external message. Existing {"body":"…"} manual-note requests retain their shipped other/note behavior and optional idempotency key; legacy prose is never parsed into a preference.

An identical typed retry returns the original fact as 200 {item,replayed:true}. Reusing the key with another channel, or across typed and manual-note modes, returns 409 idempotency_conflict without another fact, even when a manual note body equals the typed request fingerprint. Unknown typed values and a missing or invalid typed-operation key return 400. Opposite preferences written under different keys remain separate confirmed facts until a person explicitly rejects the unwanted fact through POST /crm/facts/:id/reject; no request silently supersedes either choice. Reads remain GET /crm/customers/:id/facts. Every operation uses the signed-in organisation and the existing customer-owner visibility guard, so a foreign or out-of-book customer is the same 404 not_found as an unknown customer.

The same endpoint also accepts the additive typed body {"kind":"intent","body":"…"} — the mandatory 意向/市场 (market/interest) value the manual customer-creation form collects. It stores one confirmed manual fact with category=intent, fact_key=market_or_interest and the trimmed text as fact_value, recording the caller and the confirmation time; that confirmed intent fact is the evidence that satisfies the customer's fact.market_or_interest continuity requirement. Like the preference mode it requires the x-atlas-idempotency-key header (1–128 characters), replays an identical retry as 200 {item,replayed:true}, and returns 409 idempotency_conflict without another fact when the same key carries a changed body or is reused across the intent, preference and manual-note modes. An empty or whitespace body, a missing or invalid typed-operation key, or a category alongside the typed kind return 400. Nothing else happens: no AI extraction, timeline entry, task, last-contact change or external message, and the same organisation and visibility guards apply.

Named human teams

Auth owns named human teams and one current team assignment per organisation member. These session-only REST actions require live active tenant membership, an owner/admin effective role, and members:read for reads or members:write for changes. Custom admin roles retain their current permission restrictions. Platform status and signed role claims alone grant no authority. Organisation and audit actor come from the verified session; request bodies reject organisation/actor fields. Writes require the same-origin Origin header. Existing delegated tokens gain no scope; MCP/CLI adapters and Settings controls follow in separate stories.

RESTRequestResponse
GET /auth/teams?cursor&limit&status—{items,next_cursor}
GET /auth/teams/:id?cursor&limit—{team,members,next_cursor}
POST /auth/teams{name}201 {team}
PATCH /auth/teams/:id{name,expected_revision}{team}
POST /auth/teams/:id/archive{expected_revision}{team}
GET /auth/teams/assignments/:email—{assignment}
PUT /auth/teams/assignments/:email{team_id,expected_assignment_revision,expected_team_revision}{assignment}

A team is {id,name,status,revision,member_count}; status is active or archived. Names are trimmed, nonempty, at most 80 characters, and unique ignoring case within the organisation, including archived teams. Both lists default to 50 rows and accept a maximum limit of 100. Team IDs and member emails use stable ascending order. Pass the opaque next_cursor back unchanged as cursor; it is scoped to the organisation, collection and status filter. next_cursor:null ends pagination. List status accepts active (default), archived, or all.

Assignment shape: {email,team_id,revision}. Unassigned active members initially return team_id:null and revision 0. Member rows include email, display_name, effective role and assignment_revision. URL-encode the member email. Setting a non-null team requires its expected_team_revision; removing an assignment uses team_id:null and must omit that field. Every successful assignment change returns the incremented revision; removal retains a revision tombstone. Roles are unchanged. Counts and archive checks include inactive members with assignments; an authorised owner/admin may remove those assignments.

Mutations atomically guard current authority, target membership, assignment revision and active destination revision, and persist a tenant-scoped audit event with trusted actor and before/after state. Stale revisions, archived destinations, inactive assignment targets and archiving nonempty teams return 409 with no partial data or audit change. Unknown/foreign members and team details return indistinguishable 404 responses. Unauthenticated requests return 401; unauthorised team collections return 200 {items:[],next_cursor:null}, detail reads return 404, and writes return 403. Invalid input returns 400. Authority or storage unavailability returns 503, never an empty successful collection.

Session navigation role

GET /auth/me adds user.navigationRole with one display-only value: rep, manager, or admin. Auth derives it from the signed-in caller's own active membership in the current organisation. Built-in memberships use their resolved role; an owner is still an admin here and the separate owner flag continues to select owner presentation. Every custom role presents as rep, regardless of whether its authorization base is rep, manager, or admin. An authorized role preview returns the preview role. A missing or inactive membership, an unknown custom override, an unavailable lookup, and an older response without this additive field all use the permission-filtered Rep navigation fallback.

navigationRole selects workspace layout only. It never changes user.role, user.perms, the session cookie or token claims, authorization, role assignment, or another service's access. Clients must continue to enforce every destination with the existing permission and feature gates and must never infer this value from permissions.

Current-user workspace discovery

GET /workspace/capabilities uses the signed-in cookie and the gateway's Auth binding to resolve the current userId and orgId. It returns areas with Home, Knowledge, Routine, Plugins and Skills, each with id, defaultRoute and availability. Routine is hidden until its own acceptance. modules.sales is unknown; this endpoint makes no Sales eligibility claim.

A missing session returns 401; Auth failure returns its error. The response is no-store. Query parameters and caller-supplied identity headers never select the tenant. Discovery is display metadata, not a permit: keep the existing role, permission and feature checks, and let each owner service decide access.

Next-step channel guidance

GET /agent/assistant/brief may add next_step.guidance to each focus row. Its closed state is ready, needs_review, or unknown; reason is one of no_preference, confirmed_preference, conflicting_preference, untyped_preference, missing_channel, coverage_unknown, or specialist_activity; and factIds contains at most 20 confirmed preference-fact references. Older stored briefs may omit the field.

A new generic call uses an exact confirmed preferred_channel value only: email selects the first email-capable contact and proposes the existing email activity; phone selects the first phone-capable contact and keeps the call. Purpose, due date, ranking and task identity do not change. Conflicting, legacy, unsupported, missing-contact or unavailable evidence is never interpreted or silently substituted: Today shows the review state, opens the existing customer and edit paths, and requires a fresh visible review acknowledgement after each edit before its existing accept action is enabled. Specialist activities stay unchanged and disclose the preference exception. Same-working-day accepted and dismissed decisions stay fixed; changed guidance applies to new proposals. No recommendation is sent, no task is created until the existing explicit accept, and the accept request gains no new authority field.

Stored assistant permissions

GET /agent/assistant/permissions returns {items:[{key,tier,fixed,default_tier,updated_by,updated_at}]} for all nine actions in the signed-in member's organisation. Missing rows use the catalogue default with null updated_by and updated_at; reads create no rows. Every signed-in member can read, including older sessions without permission-map entries.

PUT /agent/assistant/permissions accepts any subset, for example {"tiers":{"work.prepare":"never","work.background":"with_permission"}}, and returns the same complete table. Only the organisation owner or an admin can save; other members receive 403 forbidden. Organisation and editor come exclusively from the verified session, never client fields. Tiers are by_itself, with_permission and never. Unknown keys or tiers and malformed bodies return 400 invalid_request; naming any fixed key, even with its current tier, returns 422 permission_fixed. Rejected requests save nothing; accepted subsets are saved atomically. An unavailable store returns 503 permissions_unavailable, never fabricated defaults; an unbound database returns 503 db_unbound.

Action keyDefault tierFixed
customer.status_by_ruleby_itselfNo
customer.log_inboundby_itselfNo
opportunity.update_from_inquirywith_permissionNo
customer.confirm_suggested_factwith_permissionYes
work.prepareby_itselfNo
task.create_followupby_itselfNo
work.backgroundby_itselfNo
external.sendwith_permissionYes
commercial.price_or_promiseneverYes

These choices are stored only. They do not change assistant execution, existing AI switches or per-hire permission levels. No Settings page is added in this release.

Today workspace analytics: KPIs, pipeline and revenue trend

GET /crm/analytics/kpis, GET /crm/analytics/pipeline and GET /crm/analytics/revenue-trend power the /today sales workspace. All three are scoped to the caller's organisation only — no query parameters, no customer or rep identity. Every money field sums customer_engagements.amount, each deal's own ISO currency (customer_engagements.currency, default CNY) preserved and never converted or blended into another currency's number: every aggregate groups by currency first, then exposes a by_currency array (one {currency,value} entry per currency actually present) alongside a legacy scalar field of the same name, which carries that real figure only when the aggregate's scope resolves to a single currency (or has no deals at all) and is 0 the instant it spans more than one.

Both writes that set these columns validate them at the door: POST /crm/customers/:id/engagements and PATCH /crm/engagements/:id accept amount as a finite number, or a numeric string (e.g. "15000000"), in [0, 1e15] — anything else (negative, NaN, Infinity, over the ceiling, or non-numeric) answers 400 {"error":"amount_invalid"} and saves nothing. currency must be 3-4 ASCII letters and is stored uppercased ("cny" becomes "CNY"); an absent, null or empty value leaves the existing default alone, and any other shape answers 400 {"error":"currency_invalid"}.

GET /crm/analytics/kpis returns {monthly_revenue,pipeline,open_opportunities,win_rate_ytd}. monthly_revenue is {current_cents,previous_cents,mom_percent,current_by_currency,previous_by_currency}: current_cents/previous_cents sum won deals updated in the current/previous UTC calendar month; mom_percent is 0 unless both months reduce to the same single currency (a blended or cross-currency change is not a percentage). pipeline is {open_total_cents,weighted_forecast_cents,open_total_by_currency,weighted_forecast_by_currency}: open_total_cents sums all open (not won or lost) deals; weighted_forecast_cents sums each open deal's amount × its own probability (else its configured stage probability, else zero) ÷ 100. open_opportunities and win_rate_ytd are counts and a rounded percentage, unaffected by currency.

GET /crm/analytics/pipeline returns {items}, one row per active open-type stage in the organisation's pipeline catalogue (or the four-stage fallback on a catalogue read failure), each {stage,count,amount_cents,amount_by_currency,share_percent}. count is the true cross-currency deal count for that stage. share_percent — the stage's percentage of the whole open pipeline — is 0 for every stage together the moment the open pipeline spans more than one currency anywhere, never a percentage computed by adding yuan to dollars; a stage that happens to be single-currency itself is not rescued while another stage's deals are in a different currency.

GET /crm/analytics/revenue-trend returns {items}, 12 monthly buckets covering the trailing year (oldest first), each {month_start,total_cents,total_by_currency}: won deals bucketed by their updated_at UTC month, judged for currency independently per bucket.

Reconciled sales reports (Desktop sales results)

Three read-only endpoints power the Desktop Insights › Retrospectives › Sales results view (STORY-V2-ERP-07). They explain a reported sales number by opening the exact authorised records behind it: totals always come from server-side full-result aggregation, never from a loaded client page, and every response echoes the scope it actually used. Scope is resolved from the verified caller exactly like the Opportunities list: a rep always reports their own assigned customers (customer owner plus assigned ownership; pooled or other-owner rows are invisible), while owner/admin/manager defaults to the whole organisation and owner=me narrows to their own customer book. Deals of archived customers are excluded from every figure. No client-supplied email, organisation or actor is accepted.

GET /crm/reports/sales-summary?period_start=&period_end=&owner=me returns {report, captured_at, scope, period, pipeline, closed}. pipeline counts authorised non-archived deals by their configured current stage: items carries one row per ACTIVE org-catalogue stage in configured order (stage, stage_name, count, amount_by_currency, missing_amount_count), plus unknown_stage_count for deals sitting on deactivated or unknown stage keys and total_count. Amounts sum customer_engagements.amount verbatim — whole units of each deal's own currency — grouped one {currency,value} row per currency actually present, never converted or blended; deals without an amount are counted in missing_amount_count, never treated as zero value. closed counts each deal once by its latest persisted terminal transition (won/lost-type catalogue stage) with occurred_at <= captured_at landing inside the half-open [period_start, period_end): won and lost each carry count, amount_by_currency and missing_amount_count. A deal whose latest transition at capture is non-terminal was reopened: it is excluded and disclosed in reopened_excluded. A deal whose current stage is terminal but that has no terminal transition row has no reliable timestamp: it is excluded from every period and disclosed in legacy_excluded — updated_at is never substituted as a close date. A missing or invalid period returns 400 invalid_period.

GET /crm/reports/sales-drilldown?metric=pipeline_stage&stage=… (or metric=closed_won / metric=closed_lost with period_start/period_end; owner=me narrows like the summary; limit 1–200 default 50 and offset paginate) returns {total, items, limit, offset, captured_at, scope, period} under the same filter and access scope as the summary, so the paginated ids reconcile to the counted total. pipeline_stage requires stage — an active catalogue key or the literal __unknown__ for the disclosed deactivated/unknown bucket. Each item carries id, title, customer_id, customer_name, stage, amount, currency, stage_entered_at, updated_at and, for the closed metrics only, terminal_transition_at — the exact transition instant that put the deal in the bucket. The drilldown always recomputes at its own captured_at: a client reconciling against an earlier summary must compare timestamps and totals and explicitly mark the report changed rather than trust the old number. Malformed input returns 400 invalid_metric, invalid_stage, invalid_period, invalid_limit or invalid_offset.

GET /tasks/report-overdue?timezone=Asia%2FSingapore&limit=&offset= counts overdue open follow-ups under exactly the Home/Tasks due-day rule — open tasks whose due_at falls before the start of the caller's current local day in the requested IANA timezone — but over the caller's authorised records rather than only their own assignments: a rep counts their own tasks, while owner/admin/manager also counts the team tasks they may read; another member's private tasks are never counted. It returns {report, timezone, captured_at, day_start, total, items, limit, offset, merge_pending?} where each item carries id, title, customer_id, due_at, assignee_email, visibility; count and items come from one predicate so the paginated ids reconcile to total. Tasks being merged into another customer are excluded and reported through merge_pending like the list route. An unknown or malformed timezone returns 400 timezone_invalid; out-of-range limit/offset return 400. These are reads following the existing analytics surface: no new CLI command or MCP tool is introduced (STORY-V2-ERP-07).

One definition per business figure

GET /crm/owner/metrics?period=quarter accepts week|month|quarter|ytd|annual. The period uses UTC epoch seconds, an exclusive end and rounded elapsed_pct; weeks start Monday, and ytd ends at the read time. Missing or invalid periods return 400 invalid_period before computing metrics. Owners and admins read their organisation; managers use the same seller roster as team retrospective (currently every nonempty customer owner in that organisation); reps receive 403 forbidden before computation. Organisation and roster scope come from the verified caller, never query parameters.

The response contains period, figures, at_risk and definitions. Each figure is {value,target?,definition,by_currency}. revenue_won and deals_won sum/count won deals updated within the period; new_customers counts customers created in it; win_rate is the rounded percentage won among won plus lost deals updated in it, or null when neither exists. open_pipeline sums all currently open deals. Money figures (revenue_won, open_pipeline, weighted_forecast, stalled_deals.value.amount) never sum across currencies: each deal's own ISO currency (customer_engagements.currency, default CNY) is preserved, by_currency is one {currency,value} entry per currency actually present in scope, and the legacy value/amount field carries that same real figure only when the scope resolves to a single currency (or has no deals at all) — it is 0 the moment the scope spans more than one, never a blended number. Only quarterly revenue, new-customer and won-deal figures have saved company targets; the revenue target converts stored cents to yuan and is not itself currency-aware.

weighted_forecast sums open amount × probability / 100, using the deal's own probability first (including zero), then its configured stage probability, then zero. stalled_deals.value is {count,amount}: every open deal whose customer's current Standards activity level is at_risk or lost counts separately. No fixed 30/45-day heuristic is used.

at_risk.total counts distinct customers. Its named items carry kind, customer_id, customer_name, owner_email, reason_key, and optional opportunity_id/amount. Reasons are never_contacted (no last contact), deal_concentration (one open deal exceeds half its own currency's total open pipeline in scope — never a blended cross-currency comparison), and stalled. Each stalled customer appears once for that reason, naming the largest stalled deal and summing that customer's stalled deals into amount; unlike the figures above, this per-customer sum is not yet split by currency, so a customer stalled in two currencies at once still shows one added figure. Multiple reasons never inflate the total. Every figure definition and the definitions map, including explicit stalled and at_risk entries, provides {label,rule,standards_href} with standards_href=/knowledge/standards. This service does not change existing screen counts.

Team members read for owners, admins and managers

GET /crm/team/members?period=month returns one row per seller for a named week|month|quarter, or for an explicit start/end range in UTC epoch seconds (exclusive end, end > start, span at most 400 days) labeled custom. Sending both forms, neither, or any invalid value returns 400 invalid_period before any read. The read admits the organisation owner, admins and managers — a platform operator's tenant session carries role admin — and refuses reps with 403 forbidden before any customer or deal row is read; an owner viewing as a rep is refused the same way. Organisation and roster scope come from the verified session, never query parameters.

Each row carries email, customers, open_opps, open_amount_yuan, won_period_yuan, stale_customers, last_activity_at and a status of healthy|watch|at_risk, sorted by case-folded address, and equals the owner's Full view team row for the same period (the two reads share one implementation). Rows keep only sellers whose stored address is exactly in the retrospective roster, so a casing variant never duplicates a person. summary sums the rows (people is the row count), period.elapsed_pct is the rounded share of the period gone, and money is whole yuan. No goals, receivables, task or profile data is included.

Per-seller and team conversion rates

GET /crm/team/conversion answers where deals get stuck: for any window it returns, for each roster seller and for the whole team, the share of new deals that reached Quoted (inquiry_to_quoted) and the share of quoted deals that were won (quoted_to_won), each as {base,advanced,pct} with the counts behind it. Owners, admins and managers read their own organisation (a platform operator's tenant session counts as an admin); reps receive 403 forbidden before any deal is read. Without parameters the window is the last 90 days (end is now, start is end − 7,776,000 seconds); with explicit start and end (UTC epoch seconds, both or neither) that exact window is used and the same figures return on every later read, even after the deals in it change stage. One bound, a non-integer bound, an end not after start, or a span over 400 days return 400 invalid_period before any read.

The response carries window, min_base (5), quoted_stage ("quoted", or null when the organisation's pipeline has no Quoted stage), team and members (one row per roster seller — the same nonempty customer owners the team retrospective uses — with the seller's email and both rates). The team counts every member's deals; it never averages their percentages. A stage is at or past Quoted when it is open at or after Quoted's board position or is a won stage; a deal's initial stage is its first recorded from_stage, else its current stage. A percentage shows only when at least 5 deals stand behind it (pct is null otherwise), rounds to a whole percent, and every rule reads only stage history with occurred_at ≤ end, so a past window never changes.

The saved goals behind the monthly team review

GET /crm/team/goals?start=<s>&end=<s> answers, for one explicit window in UTC epoch seconds, the company goal covering it and every roster seller’s own monthly target, so a manager can compare a month’s result with the goals people set. Both bounds are required integers, the end must be after the start and the span at most 400 days — anything else, including the named period form, returns 400 invalid_period before any read. The read admits the organisation owner, admins and managers (a platform operator’s tenant session counts as an admin); reps receive 403 forbidden before any goal row is read. Organisation and roster scope come from the verified session, never query parameters.

The response carries month (the requested window echoed), company and members. company is the crm_company_goals row covering the window’s first day — the same latest-starting-match lookup the owner’s Full view uses — as {period_start, period_end, revenue_target_cents}, with revenue_target_cents null when the row saved no revenue target and company itself null when no saved row covers the day. members holds one row per roster seller in case-folded address order, each {email, revenue_target_cents} read from crm_member_goals at the window’s first day; a seller who never saved a target for that month answers null, never a zero. The read is read-only: goal writes stay on the owner-only Full-view routes and the rep’s own monthly-target route, both unchanged.

Deal stage changes

POST /crm/engagements/:id/stage moves one deal to another ACTIVE stage of the organisation’s pipeline catalogue — body {stage, note?, reason_category?}. An unknown or inactive stage key returns 400 unknown_stage with the org’s currently allowed keys; a stage equal to the deal’s CURRENT stage is refused 409 stage_unchanged before any write, for every stage type, not only Lost — a double-submit or a stale retry must never write a second history row or, worse, silently replace an already-recorded Lost reason with a different one. A move INTO a lost-type stage additionally requires reason_category, one of incumbent, price, sample_spec, cancelled or other; choosing other further requires a non-empty note of at most 500 trimmed characters, and that same 500-character cap applies to note on every other category too, when one is supplied — a missing or over-length reason answers 400 {"error":"lost_reason_required","missing":"category"|"note"} before any write. PATCH /crm/engagements/:id accepts stage under the identical rule: a lost-type target refuses with the SAME 400 (unless the target equals the stage already stored, which is a plain field update and needs no reason), and the transition itself always routes through the same canonical write POST uses — history, the customer-timeline row, the change-event stream, and Won auto-promotion all fire from one D1 batch, never a bare column update.

Every write appends one engagement_stage_history row and never edits an existing one: actor_kind (user today; atlas is a later story), rule_id and sources (both null until that story gives a rule something to name and read), and reason_category (set only by a Lost move) join the existing from_stage/to_stage/note/occurred_at/actor_email fields. GET /crm/engagements/:id/history returns all of them, oldest first; sources comes back as a JSON array, [] when nothing was read, never null. GET /crm/engagements/:id additionally carries lost_reason — {category, note, occurred_at, actor_email}, or null — gated on the deal’s CURRENT stage being lost-type: a deal moved back out of Lost stops carrying its old reason even though the history row recording it is never deleted.

GET /crm/engagements/:id/history additionally returns sources_resolved on every row that has sources: the same ids in the same order, each as {id, kind, label} with kind one of fact, quotation, sample, document, verification, history or unknown — a needs fact reads <fact_key>: <value>, a quotation its number (or Quotation when it has none yet), a sample its description, a document its filename, a Won verification PO verification · <po_number> (its evidence filename, or the bare words, when the purchase order states no number), and an undone stage row stage move <to_stage>. Resolution is ORGANISATION-SCOPED and costs one batched read per kind for the whole history, never one per id: an id belonging to another organisation, or to a record deleted since, comes back kind: "unknown" with the raw id as its label rather than anything about the record. A row with no sources carries no sources_resolved field at all.

Atlas stage moves, undo and rule state

Atlas moves an OPEN deal forward one step when a stated condition is met, and never writes a won-type or lost-type stage. needs_confirmed fires from inquiry when the deal’s CURRENT needs facts carry product, quantity and timeline, with product and quantity stated_by: "customer", no current fact flagged conflict, and every source note created at or after the deal’s own created_at. evaluation fires from needs_confirmed when a quotation on the deal has sent_at set and voided_at null, or a sample record has a shipped_at that has ALREADY ARRIVED (shipped_at <= now) and is not deleted — shipped_at is rep-supplied and accepted up to a year ahead, so a sample booked for a future date is a plan and never moves the deal. The triggers are the writes that create that evidence: POST /crm/engagements/:id/needs/notes and its retry, POST /crm/engagements/:id/quotations/:quotationId/send, and the sample routes that set shipped_at. A move appends one engagement_stage_history row carrying actor_kind: "atlas", rule_id (needs_confirmed or evaluation) and sources — the fact ids, or the single quotation or sample id — and writes the customer-timeline row and the change event a hand move writes.

Guards. The deal must already sit on the rule’s expected stage, both endpoints must be ACTIVE open-type keys of the organisation’s own catalogue, and the target must sort strictly after the source: a deal further along, in a custom stage, or in a won-type or lost-type stage is never moved. A rule whose previous move was undone, or which a person moved the deal back past, stays silent until sources carries an id that row did not. The expected stage travels with the write: the Atlas path refuses outright (409, nothing written) when the deal is no longer on the stage the rule READ, and the stage UPDATE additionally carries AND stage = <expected> for a move landing between the two statements — so a concurrent hand move wins, including a hand won, a forward overtake, and a person who makes the rule's own move first, and the rule writes nothing at all — no history row, no timeline row, no change event. GET /crm/engagements/:id/needs keeps reporting waiting_on (product_missing, quantity_missing, timeline_missing, product_customer_words, quantity_customer_words, conflict, source_before_deal) from the same function the rule uses, so the two can never disagree.

POST /crm/engagements/:id/stage/undo takes {history_id} and puts one Atlas move back for seven days. Permission is the same as POST /crm/engagements/:id/stage: any authenticated member of the deal’s organisation. A missing history_id answers 400 history_id_required. An unknown deal, a row from another organisation, or a row belonging to a different deal all answer 404. A row that is not an Atlas move, carries no rule_id, or occurred more than seven days ago answers 400 not_undoable. When the deal is no longer on the stage that row set, or a later stage change exists, or customer_engagements.updated_at changed between this request’s read and its write, the answer is 409 changed_since and nothing is written. On success the stage is restored with a compare-and-swap on customer_engagements.updated_at, a USER history row is appended with note undo, the original rule_id and sources naming the undone row, a stage_rule_undos row is recorded, and the customer_timeline row and change_events row for the reversal are written in the SAME batch as those two — so the deal’s own history, the customer timeline and the change stream can never disagree about whether the move was taken back: 200 {"ok":true,"already_undone":false,"stage","rule_id","undo_count_7d","paused_at"}. A second undo of the same row is not an error — it answers 200 {"ok":true,"already_undone":true} and changes nothing.

More than three undos of ONE rule in any rolling seven days across the organisation pauses that rule: paused_at is set, the rule evaluates to no move, and waiting_on still reports. GET /crm/settings/pipeline/rules returns {"items":[{"rule_id","paused_at","undo_count_7d","enabled"}]} for both rules, always both — a rule nobody has undone has no state row, and that reads as running, never as missing. undo_count_7d is recomputed from the undo ledger on every read, so it shrinks again as undos age out of the window. POST /crm/settings/pipeline/rules/:rule_id/enable clears the pause, RESTARTS the rolling window — only undos recorded strictly after the re-enable count toward a later pause, so the next undo cannot immediately re-pause a rule an admin has just brought back — and returns the same list. It rides the same pipeline capability as every other stage write, so a rep gets 403, and a rule_id that is neither needs_confirmed nor evaluation answers 400 unknown_rule.

The deals behind the monthly review’s “Why we lost”

GET /crm/team/lost?start=<s>&end=<s> lists every move into a lost-type stage of the organisation’s pipeline catalogue in one explicit window of UTC epoch seconds — each deal lost in that window with the loss reason exactly as written at the stage change (the free-text note the loss-reason dialog saves), ungrouped, oldest first. Both bounds are required integers, the end must be after the start and the span at most 400 days — anything else, including the named period form, returns 400 invalid_period before any read. Two optional query parameters narrow the same read, both scoped to the same organisation: category (an exact match on the stored reason category) and q (a case-insensitive substring match on the note) — blank or omitted, either returns every kept row, unchanged. The read admits the organisation owner, admins and managers (a platform operator’s tenant session counts as an admin); reps receive 403 forbidden before any deal row is read. Organisation scope comes from the verified session, never query parameters.

The response carries month (the requested window echoed) and items, one row per move into a lost stage read from engagement_stage_history joined to its deal and customer: {engagement_id, title, customer_id, customer_name, owner_email, to_stage, note, reason_category, occurred_at}, with occurred_at in epoch milliseconds. A stage missing from the catalogue is not a loss; a move into a won or open stage never appears. note is the loss reason as written — null when the change carried no note, an absence a reader renders as such, never an invented reason. reason_category is one of incumbent, price, sample_spec, cancelled or other — null for a row written before that column existed. Strictly a read: nothing here changes a deal, a stage or a note.

Owner insight sentences

POST /agent/owner-insight requires an active signed-in member. Send {"view":"overview","facts":[{"key":"revenue","label":"Revenue","display":"¥170K"}],"context":"optional"}. The view is one of overview, decision, work_item, performance, team, coming_up, org_health or money. Send 1–20 facts with unique lowercase keys; each label is at most 80 characters, each display at most 60, and context at most 500. The Accept-Language header selects Chinese for zh* and English otherwise.

The response is {"template":"Revenue {revenue}","based_on":["Revenue"],"generated_at":"ISO time","source":"model"}. The browser replaces each slot with the exact submitted display; model prose cannot contain its own digits, currency signs, percentages or spelled-out numbers. Invalid model output, timeout or provider failure returns a deterministic per-view sentence with source:"fallback" and all submitted facts. Invalid view or input returns 400 invalid_request; unauthenticated calls return 401. The model call is attributed and metered through the governed gateway for the signed-in organisation.

Private assistant work items

Every signed-in member can store their own jobs. Organisation and owner_email come only from the active session. Another owner's item, a cross-organisation item and a missing item all return 404 not_found for reads, updates and parent references. Unauthenticated requests return 401.

POST /agent/assistant/work-items accepts {type,title,customer_id,opportunity_id?,task_id?,source_ref?,parent_id?,thread_id?}. Required type is opportunity|touch|analysis|plan|task; title and customer_id must be nonempty strings. Missing thread_id is minted. Returns 201 {item}, or 200 {item} for the same owner's existing open source_ref. Once done or dropped, that source_ref can create a new job. parent_id must name the caller's own top-level item. Malformed input returns 400 invalid_request.

GET /agent/assistant/work-items returns {items:[]} when empty, otherwise top-level items with nested children. ?status=waiting_on_you and other reported statuses return flat matches, including children. Invalid filters return 400 invalid_status. GET /agent/assistant/work-items/:id returns {item}, including children. Items include id, owner_email, type, title, customer_id, opportunity_id, task_id, source_ref, parent_id, status, status_reason, thread_id, created_at and updated_at (milliseconds), plan and children; org_id and plan_json are not exposed.

PATCH /agent/assistant/work-items/:id accepts {status,reason?}. Stored transitions: picked_up → working/paused/done/dropped; working → paused/done/dropped; paused → working/dropped. Other transitions return 409 invalid_transition. A reason is at most 200 characters and retained only when paused.

PATCH /agent/assistant/work-items/:id/steps/:index accepts {state:"done"|"skipped"}, returning {item}. Missing indices return 404, invalid states 400, and final items or Never steps 409. A step has key, text_key, action_key, state and tier. Steps begin todo. Organisation permissions supply by_itself, with_permission or never; failed permission reads return 503 work_items_unavailable and save nothing. The first todo/running step whose tier is not never is open: picked_up/working reports waiting_on_you when that step needs permission. A step update with no open steps stores done. Never steps remain visible. This API stores progress; execution enforcement and background execution are separate capabilities.

Run work-item work in the background

POST /agent/assistant/work-items/:id/background accepts {instruction} from the signed-in owner of the work item named by :id — organisation and owner_email come only from the active session. A trimmed instruction must be nonempty and is truncated to 8000 characters like a chat turn. It starts a child analysis work item under that job, carrying the parent's customer and the stable source_ref background:<parentId>:<sha256(instruction)>: the same instruction always names the same open child, so a retry re-runs that child instead of creating another. The child is set to working and the run starts; the response is 200 {item}, the same answer when the child is already working, in which case nothing runs a second time. A parent that is missing, another owner's, or itself a child returns 404 not_found; an unreadable body returns 400 invalid_request.

The run is the same governed model loop a chat turn uses, restricted to the read-only tools and attributed through the same gateway: it may read and compare, never send, approve or change a record, and the customer never sees any of it. It finishes within 25 seconds. On success exactly one assistant message — the collected answer — is appended to the parent job's thread transcript (a transcript write replaces the whole message set, so the thread is read first), then the child's one plan step is marked done; with no open step left the child reads Done in In progress. On failure or timeout nothing is written to the parent thread; the child is set to paused with a short reason (at most 200 characters, timed out after 25s on timeout) and stays listed under its parent, which is what offers Retry.

The organisation's work.background assistant permission gates the start: never returns 403 permission_never and a tier that cannot be read returns 503 permission_unverified; by_itself and with_permission both start it, because the person's own request is the permission.

Pick a dashboard view from a question

POST /agent/view-pick accepts {"question":"How is sales doing?","locale":"en"} from an active member. The trimmed question must be 1–500 characters, and locale is en or zh-CN. It returns {"view":"team","settings":{"team":"sales","period":"quarter"}} when the governed model identifies one of nine views: business, team, person, client, decisions, coming_up, ai_team, money, or org_health. Allowed settings are sales team, a person email, a customer resolved by exact name through the caller's CRM access, and period week|month|quarter|ytd|annual. The model selects a view only; it supplies no answer or figures. An invalid or unmatched model answer returns {"view":null,"settings":{}} and logs the question for review; a model service failure returns the same fallback without logging. Invalid input returns 400 invalid_request; an absent session returns 401.

GET /agent/view-pick/unanswered?cursor= lists the caller organisation's unanswered questions, newest first, 50 per page as {items:[{id,at,actor,question,locale}],next_cursor}. The cursor follows descending timestamp and ID. Only the organisation owner or an admin sees rows; other signed-in members receive {"items":[],"next_cursor":null}. An invalid cursor returns 400 invalid_request.

Workbench customer analytics

GET /crm/workbench/customer-summary returns the current customer book for the ordinary signed-in tenant session. Owner/admin scope is company-wide, managers use their assigned active team only, and reps use their own assigned customers. Company scope includes pooled/unassigned customers; self/team scope excludes them. Live Auth authority is resolved before querying and rechecked before returning. Public responses expose scope labels, never the owner roster. Existing generic CRM permissions are unchanged. Delegated tokens and internal assertions are not admitted; MCP/CLI parity is a separate delivery gate.

Summary shape: {schema_version:1,scope,scope_revision,evaluated_at,definitions_version,customers,active,at_risk,open_pipeline:{deal_count,amount_by_currency:[{currency,amount}],unknown_amount_count}}. scope is {kind:"company"}, {kind:"self"}, {kind:"team",team:{id,name,revision}} or {kind:"empty",reason:"team_unassigned"|"team_archived"}. A valid empty scope returns zero figures. evaluated_at is the server evaluation time in milliseconds; definitions_version is customer-current-book-v1. These are current-book figures, not monthly flows or historical ownership.

customers counts all customer statuses; active counts status active, not activity level. at_risk counts unique active customers whose integer elapsed contact days exceed 30, or whose open engagement exists and elapsed days exceed 45. Days are CAST((evaluated_at-COALESCE(last_contact_at,0))/86400000 AS INTEGER); missing contact uses zero, and tasks do not affect risk. open_pipeline counts engagements outside won/lost, joined to an existing same-organisation scoped customer regardless of customer status. Current customer ownership controls scope, not engagement ownership. Non-null amounts are summed separately by currency, defaulting null currency to CNY. Amounts are whole currency units, never divided by 100 or combined across currencies. Null amounts increment unknown_amount_count; a currency with only unknown amounts has no summed amount entry.

GET /crm/workbench/customer-contributors?metric=customers|active|at_risk|open_pipeline&scope_revision=…&cursor=…&limit=… returns {schema_version:1,scope,scope_revision,evaluated_at,definitions_version,metric,totals,items,next_cursor}. totals contains all four summary measures from the same CRM snapshot as this page. Customer items are {id,name,status,last_contact_at}; pipeline items are {engagement_id,customer_id,customer_name,name,stage,amount,currency}, where name is the engagement title and customer_name comes from the same scoped join. Null amounts stay null. Default limit is 50, maximum 100; IDs sort ascending. Return the authenticated opaque next_cursor unchanged; it is bound to actor, organisation, metric and scope revision. next_cursor:null ends pagination. Refresh the displayed totals and evaluation time when opening details: CRM data can change between requests, so there is no historical snapshot across pages.

All responses use Cache-Control: no-store. Invalid sessions return 401; unsupported credentials or explicit live denial return 403; duplicate/unknown query fields, client authority hints, invalid limits/metrics or tampered/foreign cursors return 400. Missing/stale contributor revision or changed authority returns 409 scope_changed, discarding records; refresh the summary before retrying. Authority timeout, missing binding/secret, malformed authority, or corrupt/unavailable CRM data returns 503, never an empty success. Valid empty results return 200. No public organisation, owner, team or role argument is accepted.

Workbench Later reads

GET /crm/today/snoozes returns 200 {"items":[{"item_key":"task:…","until_at":1780000000000}]} for the signed-in person’s own active Later entries in the current organisation. items is empty when none remain. The read accepts no other person or organisation parameter. POST /crm/today/snooze records an item for three days without changing its underlying task, customer, deal or draft. The Workbench filters rows and counts using the returned item_key; an unavailable read keeps the last successful list, or shows every row if none has succeeded.

Quarterly seller KPI targets

GET /crm/sales-targets?quarter_start=YYYY-MM-DD returns {quarter_start,items:[{member_email,kpi,target_value,updated_by,updated_at}]}; without the query it reads the current UTC calendar quarter. Organisation owners and admins see every seller; other signed-in members see only their own rows. An unset KPI has no row and is treated as a null target, never zero.

PUT /crm/sales-targets?quarter_start=YYYY-MM-DD accepts one {member_email,kpi,target_value} and returns the stored row. The quarter start must be 1 January, 1 April, 1 July or 1 October. KPI is one of revenue_won, deals_won, new_customers, pipeline_added, win_rate, customer_touches. Values are positive whole numbers; win rate is 1–100. Money targets use whole yuan. Only an owner or admin can write (others receive 403); invalid input receives 400 without a write. Member email is normalized to lowercase, and organisation and editor come from the verified session. For week/month/YTD/annual reads, each quarter's target is prorated by overlapping UTC calendar days; win rate uses the day-weighted average of set quarters. Existing My month personal revenue targets and pipeline stage probabilities remain on their existing endpoints.

Owner decisions and held plans

GET /agent/assistant/owner-decisions returns {items,total,quote_routing_active}. Each item has id (plan:… or quote:…), kind, title, customer:{id,name}, requested_by:{email,name}, held_at and cited reasons:{title,section,quote}[]; held plans add plan_summary and proposal_id, quotes add quote_decision_id. Owners and admins see their organisation's unexpired policy holds; only owners see pending quotes while routing is active. Other members receive an empty collection.

POST /agent/assistant/owner-decisions/:id/decide accepts {decision:"approve"|"reject",reason:string}. The trimmed reason must contain 10–500 characters; missing or invalid reasons return 400 invalid_reason before authorization. Held plans require a signed-in owner or admin; quotes require the owner. Other callers receive 403 forbidden. Approval invokes the existing proposal confirmation with the reason as its policy override and checks policy again. CRM quote decisions run under the owner's session and preserve 404 not_found and 409 conflict.

A blocked proposal waits seven days from creation, including one escalated by policy_changed. The same unchanged message reuses its pending, rejected or expired hold. Direct confirmation or rejection by a rep returns 403 owner_decision_required and owner_decision:{outcome,reason,at}; the notice only explains waiting, rejection or expiry and offers no Override. Inspect also returns this metadata. To request another decision after rejection or expiry, edit the message. Non-held proposals retain their 15-minute expiry. Approved messages use the ready organisation mailbox, otherwise OUTREACH_FROM_EMAIL, with Reply-To set to the proposing rep. A missing Atlas address is a definite send failure.

Rejection and expiry create exactly one task for the proposer, on the plan's customer, with the reason and a due date that day. Failed task delivery is retried by the five-minute sweep using owner_decision and plan:<proposal_id> as the idempotent source. GET /agent/assistant/owner-decisions/log?cursor=… returns {items,next_cursor}, newest first, 50 rows per page. Rows contain id,at,actor,decision,item_title,customer_name,reason,outcome; expiry actor is system. All reads and writes are organisation scoped. Apply migration 0025_owner_decision_log.sql before the Agent worker: it adds the log and retains immutable proposal content while permitting only the exact seven-day policy-hold expiry extension.

Prepared send status for earlier attempts (#3825)

GET /agent/prepared/:preparedWorkId/send-state answers {"state":"none"} only when the authenticated organisation's draft has no send attempt. A durable CRM command remains authoritative and returns its command id and retry fields. If no command exists, an older outbox attempt returns {"source":"legacy","state":"sent","can_retry":false}, {"source":"legacy","state":"failed","can_retry":false}, or {"source":"legacy","state":"queued","can_retry":false}. A legacy answer may carry cause or message_id when recorded, but never a command id. Sent means mail-server acceptance, not customer delivery; queued has no recorded outcome. Review failures and unresolved attempts before considering a new approved send. The command retry endpoint requires a real prior command id and cannot retry these older attempts. Reads are scoped to the caller's organisation. If the CRM status read fails or is malformed, the endpoint answers 503 send_state_unavailable; only an authenticated CRM 200 {"state":"none"} becomes the public {"state":"none"}. No send or retry is inferred from an unavailable read.

My performance figures

GET /crm/analytics/performance?member=me&period=quarter accepts member=me or a seller email and period=week|month|quarter|ytd|annual. A missing or invalid period returns 400 invalid_period before any computation. Owners and admins read any member; a manager reads the same seller roster the team retrospective shows (currently every nonempty customer owner in the organisation) or themself; every other member reads only themself — comparisons are case-folded and an unauthorised member returns 403 forbidden. Organisation scope comes from the verified session, never a query parameter.

The response is {period,member,kpis,revenue_forecast}. period uses UTC epoch seconds with an exclusive end and a rounded elapsed_pct; weeks start Monday, and ytd runs 1 January to the read time (elapsed 100). kpis carries the six KPIs revenue_won, deals_won, new_customers, pipeline_added, win_rate, customer_touches, each as {kpi,you,you_by_currency,target,prorated_target,team_mean,forecast,pace} with pace one of ahead, behind, on, no_target. you is the seller's own figure: won deals sum and count by updated_at; pipeline added sums engagement amounts by created_at; new customers count the seller's customers created in the period; win rate is the rounded percentage of won among won plus lost deals (null when neither exists); customer touches count the seller's timeline rows of the communication types. For the two money KPIs (revenue_won, pipeline_added), you is null — never a blended number — the moment the seller's own deals for that KPI and period span more than one currency; you_by_currency (one {currency,value} entry per currency actually present) always carries the real figures, and a seller whose you is null this way is excluded from team_mean rather than averaged in as a false zero.

Targets come from the quarterly seller KPI targets: weeks and months prorate each overlapping quarter by UTC calendar days, win rate uses the day-weighted average, and an unset KPI returns target and prorated_target as null with pace:"no_target" — never a fabricated number. prorated_target is the period target × elapsed share; rates are not prorated. team_mean averages the you of every seller with a stored target for that KPI in a quarter the period overlaps (null when none), so a seller without a target never drags the mean toward zero. Money is whole yuan of the deal's own currency; every other figure carries one decimal.

Count forecasts (deals_won, new_customers, pipeline_added, customer_touches) add the trailing 56-day weekly rate × weeks left; the win_rate forecast is the trailing 8-week rate itself; revenue_won.forecast equals revenue_forecast.total_yuan. revenue_forecast is {won_yuan,won_by_currency,open_weighted_yuan,open_weighted_by_currency,open_deal_ids,trend_yuan,trend_by_currency,total_yuan}: won-in-period revenue plus every currently open deal × its own probability (else its configured stage probability; neither present means zero) plus the trailing-56-day won revenue ÷ 8 × weeks left. Each of won_yuan, open_weighted_yuan and trend_yuan is 0 — never a blended sum — the moment its own deals span more than one currency; the matching won_by_currency/open_weighted_by_currency/trend_by_currency array always carries the real per-currency figures. total_yuan adds those three legacy figures as plain numbers, so a seller whose won, open and trailing-trend deals each resolve to a different single currency in the same period still gets an added-across-currencies total — the one figure on this endpoint not yet currency-safe. This endpoint computes figures only; it stores nothing and changes no screen counts.

Release mode, effective date and rollback

Publishing a policy release becomes a deliberate step (#3949). POST /knowledge/policies/:id/releases accepts, beside base_version and answerable, an optional release_note (text, at most 500 characters), mode (enforce — the default every pre-existing release carries — or warn_only), enforce_at and effective_at (epoch-milliseconds integers), and notify: {readers, plan_owners} (both booleans, required together). Every field is optional: a body from before this change publishes unchanged. enforce_at applies only to a warn_only release; a wrong combination or shape answers 400 invalid_request and creates no release. The workflow twin refuses these fields: they are policy-only.

Evaluation honours the staging. A release whose effective_at is in the future is excluded from POST /internal/policy/evaluate until that date arrives (its id still counts toward policy_set_revision, so publishing it is observable to pinned consumers). A warn_only release evaluates as warn until its enforce_at passes and as enforce from that instant on; a would-be block is still recorded — the reason is returned with outcome: "warn" and mode: "warn_only", and the response's top-level would_block is true. Every reason carries the mode it was evaluated under; the strongest outcome still wins across releases; a release without dates and with the default enforce mode evaluates exactly as before.

POST /knowledge/policies/:id/releases/:releaseId/rollback (manager/admin, optional body {"release_note": "…"}) republishes a named earlier release's stored content as a NEW immutable release that records rolled_back_from_release_id. The new release is enforce with no dates: it exists to take effect now. The source is resolved scoped to the caller's organisation AND the policy in the URL, so another organisation's release, another policy's release and an unknown id are the same plain 404 — and no release is created. Published releases are never edited in place; history is immutable.

GET /knowledge/policies/:id/releases lists releases newest first, each carrying mode, enforce_at, effective_at, release_note, withdrawn_at and rolled_back_from_release_id beside the fields it always served; the single-release GET and the publish response carry the same fields. Notify: notify: {readers, plan_owners} is validated but Atlas has no per-user notification transport today, so nothing is pushed. What a publish observably does: the release note reaches readers on the releases list, and the policy_set_revision changes, so the agent’s existing policy pin answers policy_changed (“Guidance changed — review again.”) at the next confirmation. The engagement-plan notice “Guidance changed — refresh?” is computed by CRM from playbook-version pins and is not affected by policy releases; per-reader push and that notice are a recorded gap with a follow-up.

Scenario cases stored with the policy (#3950)

Every policy can carry up to 20 saved scenario cases — a customer class, an action category, an actor role and the outcome the owner expects — stored with the policy itself in memory_v2_policy_cases (migration 0031_policy_cases.sql). GET /knowledge/policies/:id/cases answers {items, last_run} for every signed-in role; each item carries {id:"mpcase_…", title, customer_class, category, actor_role, expected_outcome, expected_section_id, created_by, created_at}. Another organisation's id and an unknown one are the same plain 404 not_found.

PUT /knowledge/policies/:id/cases accepts {items:[…]} and replaces the whole set under the Knowledge-rules capability (admin and manager today); ids are minted by the server. Each item must carry exactly {title, customer_class, category, actor_role, expected_outcome, expected_section_id}: title 1–200 characters, category one of task-creation|record-change|draft-preparation|external-communication|administration, expected_outcome one of none|warn|block, the two facts a string of 1–120 characters or an explicit null for known-absent, and expected_section_id null or one of the policy's own section ids — anything else, or a 21st case, is a 400 invalid_request with nothing written.

POST /knowledge/policies/:id/cases/run accepts {draft?: boolean} (absent means the draft) and answers {run_id:"mpcrun_…", items:[{case_id, expected, actual, section_id, pass}], passed, failed, ran_at}. The saved cases are evaluated with the policy's draft rules (or its current release when draft:false) together with every other published policy of the organisation through the same evaluatePolicySet evaluator; a fact the case leaves unset is known-absent and matches nothing, so actual is always none|warn|block and pass is exactly actual === expected. Every run is appended to memory_v2_policy_case_runs and stays readable as data — a case run never sends a notification.

Publishing is gated: POST /knowledge/policies/:id/releases runs every saved case against the content about to be released and refuses with 422 {"service":"atlas-knowledge","error":"cases_failing","failed_case_ids":[…]} while any case's actual outcome differs from its expected one; nothing is published and no run is stored by the refusal itself. When all saved cases pass, Publish proceeds and shows the pass count from the stored run. Cases re-run automatically on publish (the run is stored against the new release) and whenever catalog_hash — the sha256 of the action catalog's category map alone, so ordinary catalog metadata changes never trigger it — differs from the stored last run: the earliest cases read repairs the stored results. A release the evaluator cannot read refuses the publish with 503 policy_unavailable rather than releasing blind. Errors: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 422 cases_failing, 503 storage_unavailable. Reads are organisation-scoped on every path; no request field can select a tenant.

Proposed policy sections and rules from a Company Doc (#3952)

POST /knowledge/policies/proposals/from-document accepts {document_id} from an owner or admin (the Knowledge-rules capability). The named document must be a Company Doc in the caller's organisation that the caller can read; that read is organisation-scoped before anything is generated, and a document that is absent, belongs to another organisation, or is another member's personal file answers 403 document_not_readable and generates nothing. Generation makes one call through the governed Workers AI gateway, with the document's own passages as the only source, attributed to the organisation and the workflow. The answer is {proposal_id, document:{id,title}, sections:[{index,title,body,source_passage}], rules:[{id,sentence,applies_to:{categories},when,outcome,section_index,source_passage,checkable}]}. Every item's source_passage is verified server-side as a verbatim substring of the document, and an item whose quote fails the check is dropped, never returned. A rule whose predicates are not all among the three trusted facts — the action category in applies_to, actor_role, customer_class — returns "checkable":false with no when clause. A proposal expires after 7 days.

POST /knowledge/policies/proposals/:id/accept accepts {keep:[rule_id...], edits?:[{rule_id, sentence?, outcome?}]} and creates one draft policy holding the proposal's sections and exactly the kept and edited rules, authored atlas:<document_id> in the draft's source field; an edited sentence is appended to the body of the section the edited rule cites, and an edited outcome replaces the rule's outcome. Another organisation's id and an unknown id are the same 404 not_found; a proposal older than 7 days answers 410 proposal_expired; unknown keep ids or edits to unkept rules answer 400 with nothing written. Nothing on this surface publishes: the draft is born Draft with no release, and Publish remains a separate, explicit step.

Stored policy proposals list (#3968)

GET /knowledge/policies/proposals (the Knowledge-rules capability — admin and manager today) lists the organisation's stored proposals newest first, capped at the most recent 50. Each item carries {id, document_id, document_title, created_by, created_at, expires_at, kind, value, source_ref}. kind is derived at read time from the stored payload: "policy" for a sections-and-rules proposal (#3952) and "standard" for a Company-Doc standard draft, whose proposed value and source_ref ride along; a policy proposal reads value and source_ref as null. The read is org-scoped by the session only and strictly read-only — a proposed value is never treated as a fact. Collection semantics: a caller without the Knowledge-rules capability reads the same 200 { items: [] } as an idle organisation (the Standards page renders exactly that as its "No proposed standards" state), never a refusal; a broken store answers 503 storage_unavailable.

One recorded override's detail (#3962)

GET /agent/assistant/policy-overrides/:id (admin or manager — the same roles as the overrides list beside it; anyone else receives the plain 404) answers {item}, one recorded override's entry detail: {id, at, actor_id, operation, proposal_id, subject_ref, customer_id, reason, policy_set_revision, category, customer_class, actor_role, sections}. The two facts the override row itself does not carry — the action category and the customer_class — are recovered from the decision row written in the same batch; a lost log row (its writes are best-effort) degrades to the operation's own catalog category and an unset class, never to a failed read. sections lists every cited block reason as {policy_id, policy_title, section_id, heading, quote} — exactly the facts the "Make a rule from this" draft reads. Org-scoped by the verified session: another organisation's id and an unknown one are the same plain 404 not_found, never a cross-organisation record; an unavailable store answers 503 store_unavailable.

Draft a policy from a recorded override (#3962)

POST /knowledge/policies/drafts/from-override accepts {override_id} behind the Knowledge-rules capability — the same boundary the policies studio's writes sit behind (admin and manager today) — checked before anything is read, so a caller without it is refused 403 forbidden and no draft is made. The named override is read strictly through the Agent's org-scoped GET /agent/assistant/policy-overrides/:id with the caller's own session forwarded, so another organisation's id and an unknown one are the same plain 404 not_found and no draft is created; a read that cannot be completed answers 503 override_unavailable, never a half-draft.

The draft is built from the incident itself: one section whose body opens with the cited section's heading and quote and appends the overrider's own recorded reason, and one schema-2 rule applying to the incident's action category with eq predicates on the customer's class and the actor's role when the override recorded them — an unset fact leaves that predicate unset rather than blocking the draft. The rule's outcome is block: tightening or loosening happens in the editor, never here. The draft is stored with source = "override:<id>", born Draft with no release — nothing on this surface publishes. The answer is 201 {item, replay}: the item in the same shape the proposals' accept serves, plus replay, the rules preview evaluated over the organisation's last 30 days of proposals, so the editor can show what the new wording would have done. Errors: 401 unauthorized, 403 forbidden, 404 not_found, 400 invalid_request (a missing or oversized override_id), 503 override_unavailable and 503 storage_unavailable.

Policies applying to one customer (#3963)

GET /knowledge/policies/applying?customer_id= (browser session; every signed-in role, like the policies studio's own reads; the organisation comes from the session only — no request field can select a tenant) answers { items: [{policy_id, title, release_no, section_ids, outcome_kinds, sections}] }; each item also carries sections: [{section_id, heading, heading_zh}] — the readable names for the cited sections (the published headings, Chinese beside English when stored), zipped to section_ids or omitted whole when the stored ids and section records disagree, so the customer header chip renders names, never raw ids: the organisation's published policies carrying at least one schema-2 rule that applies to that customer. Only a rule's own customer_class predicates decide — a rule with no class predicate applies to every customer, a known-absent class matches nothing, and an actor_role predicate constrains the actor, not the customer, so it never removes a policy from the answer. section_ids lists the sections the applying rules cite and outcome_kinds the kinds they carry (warn, block); section bodies are never returned.

The customer's class is resolved through CRM's internal classes read, never an EKB table. This is a collection endpoint: an unauthenticated caller, a missing or malformed customer_id, an unknown or other-organisation customer, a CRM read that cannot be completed and a broken published-set store all answer the same 200 { items: [] } — never a 404, never a guessed applicability.

Procedures as cited instructions (#3966)

A published procedure (a workflow release) may declare who and what it applies to — an optional applies_to of {categories, customer_class, country}, three optional lists of non-empty values at most 20 long and 120 characters each — and any step may carry cites: {policy_id, section_id} naming the policy section behind it. The stored shape is frozen: an unknown key, an oversized value or a non-list is refused with 400 invalid_request on the workflow's create and PATCH /knowledge/workflows/:id. Only a mentioned applies_to moves on a PATCH: absence leaves the stored declaration alone, so a save from a client that does not model the field can never silently drop it, and null clears it. Publication snapshots the declaration byte-exact with the rest of the content, and a withdrawn release is never matched again.

GET /internal/procedures/match?org_id=&category=&customer_class=&country= is an EKB-owned Service Binding interface for Atlas services, not a public REST endpoint, signed with the same internal HMAC as POST /internal/policy/evaluate (method, path and query bound). The Agent's prepare paths ask which published procedure governs the customer-facing draft they are about to write, and receive the newest published version whose applies_to matches, or a literal null. org_id is required and covered by the signature — there is no session principal on an internal call, so a tampered organisation is a failed signature — and category must be one of the five action categories. A procedure that declares no applicability never matches, and every dimension the procedure did declare must match: a declared dimension with an unknown query fact reads as not matching, so the answer is never a procedure whose conditions cannot be verified. Strictly read-only; a missing secret, an unbound database or a storage error answers 503, never a silent null — only a real evaluation may answer "no procedure applies".

Atlas Studio synthetic graph drafts

This candidate registers the EKB graph draft producer. Authoring, catalog and layout access require explicit synthetic configuration; absent STUDIO_GRAPH_DRAFTS_MODE=synthetic, STUDIO_GRAPH_DRAFTS_CURSOR_KEY (at least 32 characters), condition budgets or the additive EKB sidecar schema leaves it unavailable. Retained command acknowledgements remain readable with current authorization and journal access even when those authoring settings are removed. These routes do not activate a graph, send messages, reserve a model or publish proof. Existing /ekb/workflows procedures remain separate.

Use an ordinary organisation session cookie. Auth refreshes the session, membership, native role and effective permissions on every operation, including command replay. Reads require current knowledge:read; authors require the native admin or manager role and current knowledge:write. Signed stale owner claims and view-as cannot expand current permissions. Platform, delegated-token and incoming principal-assertion authority is not admitted. Missing authentication is 401 authentication_required; unavailable current authority is 503 dependency_unavailable.

Method / pathInput and result
GET /ekb/studio/workflows?limit=10Explicit limit=1..100, optional signed cursor. Returns {schema_version,request_id,items,next_cursor}; authenticated readers without permission receive an empty collection.
GET /ekb/studio/workflow-catalogs?limit=10Canonical catalog collection; each catalog exposes exact typed ports, configuration fields, identity mappings, schema refs and explicit condition limits. Read one version at GET /ekb/studio/workflow-catalogs/:catalog_version.
PUT /ekb/studio/workflow-layouts/:layout_idCanonical StudioGraphLayoutDocument; the URI must equal its content-derived identity. Exact replay preserves the original immutable row. Read or reconcile a lost response using GET /ekb/studio/workflow-layouts/:layout_id.
POST /ekb/studio/workflows{command_id,definition}; requires an immutable layout stored through the layout URI and exact owner catalog/schema/port/mapping bindings.
GET /ekb/studio/workflows/:id/draftReturns {schema_version,request_id,item} with definition, revision, digest, lifecycle and updated timestamp. Missing or foreign detail is the same 404 source_unavailable.
PATCH /ekb/studio/workflows/:id/draft{command_id,patch}; patch.id must match the command, and the target, expected_revision and base_digest must identify the current draft. A stale source returns 409 revision_conflict.
POST /ekb/studio/workflows/:id/draft/validate{definition_ref}; returns an item-wrapped validation with valid, invalid or blocked, safe field issues and checked timestamp.
GET /ekb/studio/commands/:command_idReturns an item-wrapped command status. Keep the original command ID and payload after a lost response. not_observed requires status reconciliation and does not prove that work was never executed.

Commands return item-wrapped StudioCommandStatus; completion is 200 and admitted pending work is 202. Invalid schema/catalog/ports/mappings refuse the write and retain the prior draft. Different content under one command ID is 409 idempotency_conflict; exact replay returns the original result after fresh authorization. Errors use {error:{code,message_key,request_id,retry,field_errors?}}. Responses are Cache-Control: no-store with Vary: Cookie, Authorization. JSON is bounded at 64 KiB; unknown authority fields and duplicate/unknown query parameters are refused.

Read the catalog first with GET /ekb/studio/workflow-catalogs?limit=10, then the draft collection with GET /ekb/studio/workflows?limit=10. The adopted internal.condition.v1 language has typed field(...) references, comparisons, and/or/not, strict UTC time(...) and injected now(). Present null differs from unavailable missing_fact; unknown follows three-valued Boolean tables. All six parser/evaluation/read budgets are explicit catalog values; no JavaScript, property traversal, callback or I/O is evaluated. Retain the exact current reference before editing. Every accepted edit invalidates earlier preview bindings. Collection cursors bind actor, organisation, page limit and collection revision; a changed snapshot returns a conflict and requires a fresh list. Owner scanning is bounded; unavailable policy/storage never becomes a successful empty result. There is no delete, legacy conversion, activation or publication command here.

Synthetic graph preview journal (owner handler)

The Agent owner handler supplies the canonical preview/journal interface for coordinated central dispatch. Its registration and capability discovery are separate integration steps; no production availability or activation is implied. It requires an ordinary native session, current Auth membership, current knowledge:write and native admin/manager authority to create a preview. Reads use current knowledge:read. Bearer, incoming assertion and platform authority cannot substitute for that session.

Server prerequisites are explicit STUDIO_GRAPH_PREVIEW_MODE=synthetic, a cursor key, condition/controller budgets and STUDIO_GRAPH_PREVIEW_PROOF_POLICY with {version,evidenceTtlMs,retentionMs,maximumTraceRows,requiredCoverage:"complete"}. There are no production policy defaults. For new computations and live proof reads, missing dependencies/policy return 503 dependency_unavailable; absent synthetic mode returns 503 feature_unavailable. Historical command reconciliation requires current Auth and journal access, independently of current EKB/computation configuration. Retention bounds serving of immutable records; this handler does not physically purge them or grant erase/share rights.

Method / pathContract
POST /agent/studio/previews{command_id,definition_ref,mode:"preview",model_source:"synthetic",scenario_ref,cohort_ref,override_ref,injected_time}. The server resolves the exact EKB draft/catalog/validation and stores the result, trace and original command receipt. No live adapter or client proof is accepted.
GET /agent/studio/previews/:idCanonical item-wrapped snapshot with explicit synthetic mode, effect-incapable adapter, outcomes and optional synthetic evidence. A stored evidence link is discovery only, never permission.
GET /agent/studio/previews/:id/trace?limit=10Explicit limit=1..100, optional signed cursor. Canonical trace pages bind preview/ref, actor/org, immutable snapshot and increasing sequence; public rows carry authorized references, not raw private input.
GET /agent/studio/commands/:command_idItem-wrapped historical command status, currently authorized and integrity checked. Source/proof expiry invalidates live proof without rewriting the original retained command outcome.
GET /agent/studio/preview-evidence/:evidence_idItem-wrapped immutable StudioPreviewEvidenceRecord, rechecked against the computation, current source and real expiry clock. The client cannot mint publication authority by hashing an object.

The finite source adapter accepts synthetic.positive.v1, synthetic.negative.v1, synthetic.zero.v1, synthetic.null.v1 or synthetic.unknown.v1, with synthetic.cohort.single.v1 and synthetic.override.none.v1. Scenario time is injected independently of actual proof issuance/expiry. Branch previews can complete with partial coverage and no proof; failed nodes retain safe diagnostics. No model reservation, domain write, send or live dispatch occurs.

A durable pending reservation precedes computation. 202/pending means admitted work, not completion. Preserve the original command ID/payload and reconcile status; pending never proves non-execution or authorizes a new automatic retry. Finalization compares the pending snapshot and atomically retains computation, trace, evidence and final receipt. Exact final replay returns the original status; changed content returns 409 idempotency_conflict. Immutable computation snapshot 2 remains separate from a later inline proof projection: actual issuance time is preserved and the projection advances revision/time. These are synthetic preview records, not publication, activation or UAT acceptance. Live source/version/policy changes or expiry return 409 stale_preview; missing/foreign detail is 404 source_unavailable. All responses are no-store and vary on Cookie/Authorization.

Inspect your stored synthetic preview runs

Under the same STUDIO_SYNTHETIC_PILOT_ORGS and STUDIO_GRAPH_PREVIEW_MODE=synthetic pilot, an ordinary native session with current knowledge:read can inspect its own final stored graph previews. The owner rechecks current Auth membership, feature entitlement, creator/organisation, current EKB graph visibility, retained journal integrity and the stored retention deadline on every request. Recorded completion remains historical after evidence expires or the graph is edited; it does not assert current proof validity. The existing evidenceTtlMs still limits live preview evidence, while history is available only within its stored retentionMs. Live preview, trace and evidence endpoints continue to reject expired or changed proof. Bearer, platform and incoming assertion credentials cannot substitute. This read-only view does not publish, activate, execute staff, read mission records or query another service's tables.

Method / pathResponse
GET /agent/studio/runs?form=graph&limit=10{schema_version,items,next_cursor}, using atlas.studio.run-inspect.v1. Only creator-owned final preview IDs are listed.
GET /agent/studio/runs/:run_idDirect RunInspectionView: recorded configuration, execution, effects, cost, failures and uncertainties. run_id is the stored studio-preview:… ID. Mode is preview, effect capability is incapable, and unrecorded cost remains null.
GET /agent/studio/runs/:run_id/events?limit=10Direct RunEventPage with observed graph_preview/graph_preview_trace sources, stable event IDs, recorded times, next_cursor, anomalies and resync_required. No transition or successful effect is invented.

List and event reads require limit=1..100 and accept an optional cursor; list also requires form=graph. Missing/broader/staff/project form selection and non-preview run kinds return 503 unsupported_capability. Other creators' or organisations' preview IDs return 404 not_found. Unsupported methods return 405; malformed or duplicate query fields return 400 invalid_definition.

Cursor MAC inputs are domain-separated from existing preview trace cursors and bind organisation, actor and stream. Continuations name only the last returned authorized ID. Policy changes return 409 resync_required; expired cursors return 409 stale_cursor; invalid or cross-surface cursors return 400 invalid_cursor. Follow restart_without_cursor when supplied. Current permission failures remain refusals; unavailable current graph visibility or damaged retained data returns 503 dependency_unavailable, never an apparently complete empty history. Rows past their stored retention deadline are excluded before the bounded index scan; their detail and events return 404 not_found. An empty page describes retained history only. Collection scanning checks at most 128 candidates with one overflow sentinel. Persisted fields are capped at 65,536 UTF-8 bytes in SQL before materialization; trace rows use the existing proof policy's maximumTraceRows plus an overflow sentinel. Overflow refuses; no partial JSON or truncated complete-looking stream is returned. Responses use Cache-Control: no-store and Vary: Cookie, Authorization.

Rule-driven status changes in Done for you (#3915)

One permission row, customer.status_by_rule — "Change a customer's status when its rule is met" — governs both of a customer's rule-driven statuses: the contact state from Knowledge → Standards and the warmth label. It is a ceiling over the organisation's standard: By itself follows the per-transition auto/propose policy and applies every warmth drop; With permission turns every transition into a proposal and every warmth drop into a pending hold plus one warmth_change suggestion in Decide; Never applies and proposes nothing while trend, days_since_activity, next_expected_at and computed_at are still recorded. A warmth drop applied under By itself, or accepted in Decide, writes a timeline entry of type warmth_change with deterministic id warmth:<org>:<customer>:<utc-day>.

GET /crm/assistant-changes?since=<ms-epoch> (owner-scoped per #1509: a rep's feed lists only changes on customers assigned to them) returns 200 {"items":[...]}, newest first, capped at 200 rows; since defaults to 24 hours ago. Each item carries id (the change's timeline id), customer as {id,name}, from, to, rule (activity_level_change or warmth_change), permission_key (customer.status_by_rule), occurred_at and, when someone took it back, undone_by and undone_at. A reversal is a different timeline type and is never listed as a change.

POST /crm/assistant-changes/:id/undo restores the previous value and is idempotent: a repeat returns the stored result. The activity engine restores previous_level on crm_customer_activity — undoing a change the customer has since moved past answers 409 {"error":"status_moved_on"} rather than clobbering newer state; warmth writes an undone hold instead. Both bind the previous value in crm_status_rule_holds (additive migration 0107_status_rule_holds.sql) to the customer's current last_contact_at, so every read path shows the previous status until the customer's next contact, and both record a status_change_undo timeline entry with deterministic id undo:<change id>. A change id from another organisation, or on a customer the caller may not see, answers the same 404 {"error":"not_found"} as an unknown id.

#3916 adds the two inbox kinds to the same feed and undo. When the organisation's customer.log_inbound tier is by_itself, an inbound email whose stored match snapshot holds exactly one exact winner (comm_match_proposals.state = 'proposed') is auto-logged through #259's confirm path with actor assistant:<owner member email> and link source = 'assistant'; ambiguous, unmatched, outbound and lower-tier items keep the human review, and a new customer from an unknown sender always waits for a rep's confirmation. The feed lists these as rule = "inbound_log" rows with permission_key customer.log_inbound, plus subject and match_reason (exact_email/exact_phone). When opportunity.update_from_inquiry is by_itself, the planner's deal/stage proposal is applied through the same exactly-once confirm (its receipt keeps it idempotent) and listed as rule = "opportunity_update" with proposal_kind and from/to naming the stage move or the created deal's stage; at never the planner builds no deal/stage proposal at all. The email body is never an input to any of these writes — only the matcher's stored state, the classification's structured answers and the stored tiers decide.

Undoing an inbound_log removes the link, retracts the assistant's timeline entry (the one path that may remove a synced-entry row, and only because its actor is assistant:* — the human delete/correct refusal is unchanged), deletes the comm_item_reviews row, returns the email to the review queue, and recomputes last_contact_at from the latest remaining counted entry — all in one batch. Undoing an opportunity_update restores the snapshotted prior stage and fields, or removes an engagement the assistant created; a CAS on the engagement's updated_at answers 409 {"error":"changed_since","changed_by":"..."} when a person edited the deal after the assistant did. GET /crm/communications/triage-summary also carries an additive email_excerpt on each attention item — a 280-character bounded quote of the stored message for the Decide rows.

Editable customer notes and colleague-only reflections

PATCH /crm/customer-notes/:id accepts {"body":"…"} and returns 200 {item} with the new body, the original created_at and a moved updated_at. DELETE /crm/customer-notes/:id returns 204. Both are author-scoped: only the signed-in author of the note may edit or delete it, and any other reader of the same customer receives 403 forbidden. A note id outside the caller's organisation, and a note on a customer outside a rep's own book, are both 404 not_found — the same answer as an unknown id, with nothing changed. Body is required after trimming; an empty body returns 400 body_required. There is no character cap, because the note was created without one through POST /crm/customers/:id/notes.

A delete is refused with 409 note_in_use and {"in_use":N} naming the count while N extracted facts still cite the note as their evidence through customer_facts.source_note_id. The reference is never nulled: an AI-derived fact keeps the note it came from, so its basis stays traceable. Rejecting a fact through POST /crm/facts/:id/reject does not remove its row, so a note that produced facts stays as their source. Notes gain an updated_at column through migration 0108_customer_note_updated_at.sql; rows written before it are backfilled to their created_at, so the field is never null.

POST /crm/customers/:id/facts accepts an optional category from preference, family, career, health, taboo, intent, relationship, other, reflection. Omitting it is unchanged behaviour: one confirmed manual fact with category=other, fact_key=note and no expiry. An unknown value returns 400 invalid_category and writes nothing, and a category sent alongside the typed {"kind":"contact_preference"} body is refused the same way, because that kind fixes its own category. reflection records a colleague's judgment about the customer rather than a checkable fact about them: it is stored, read and handed over like any other fact, never expires, and is excluded from customer-facing content by category whatever its status. The AI extractor cannot produce one — only a person may record their own judgment. Reusing an x-atlas-idempotency-key with a different category returns 409 idempotency_conflict.

Channel identity on the customer timeline

GET /crm/customers/:id/timeline adds channel to every entry: email, wecom or whatsapp for a row that references a confirmed communication item (ref_type:"comm_item"), and null for every other row, including a manually logged touchpoint. A confirmed item mirrors into the timeline as type email or type message, so until now a WeCom conversation and a WhatsApp one read identically; the channel is recovered from the referenced item rather than stored a second time.

The field is additive and needs no migration. type, ordering, paging (before, limit), the scope=communications and scope=customer-history allow-lists and the synced marker are unchanged. The lookup is organisation-scoped, so an entry whose reference belongs to another organisation reads as null, and it selects the channel alone — no artifact locator from the source message is ever returned.

Channel, provenance and deal on the Communications timeline

GET /crm/customers/:id/timeline every entry now carries provenance — an object {kind, by} with kind one of synced, sent, assistant or manual: assistant when the row's actor_email starts with assistant: (the assistant acted for the member named after the prefix, who is by); synced when the row references a confirmed communication item (ref_type:"comm_item"), by being the person who linked it; sent for an email_sent row (an email Atlas sent after by approved it); manual otherwise, with by equal to the row's actor_email. Entries also gain engagement_id and engagement_title, naming the deal the communication is about; both are null when there is none, and a deleted deal clears the pointer back to null. channel is now also returned for a hand-logged row when the recorder stored one (read from the row's metadata JSON), and stays null otherwise.

POST /crm/customers/:id/timeline accepts an optional channel — wecom, whatsapp or email — stored in the row's metadata; an unknown value writes nothing and returns 400 invalid_channel. It also accepts an optional engagement_id, which must name one of the customer's own deals or the call writes nothing and returns 404 engagement_not_found. PATCH /crm/timeline/:id accepts the same two fields, each clearable with null; the existing rule that only a recorder-authored row may be corrected is unchanged.

POST /crm/communications/:id/confirm accepts an optional engagement_id, validated against the confirmed row's customer_id — an id belonging to a different customer returns 404 engagement_not_found and confirms nothing. The stored result gains engagement_id (null when none set) and replays it on a repeat confirmation exactly like every other field already returned there.

GET /crm/customers/:id/communications/pending is new: it returns 200 {items: []}, or items of {id, channel, direction, occurred_at, match_state} for synced messages still awaiting review whose stored match snapshot (comm_match_proposals) names this customer as a candidate — never one already confirmed, discarded or linked. Results are bounded by limit (default 50, maximum 100); no subject, body or attachment locator is ever returned. The read is organisation-scoped and further narrowed by the caller's readable-mailbox grants and a rep's own-book scope; it feeds the customer page's "waiting to be linked" banner, and a person still confirms every link by hand. GET /crm/communications/queue?view=pending accepts an optional customer_id, narrowing the queue the same way — to items whose match candidates name that customer — which is how the banner's "Review" link opens the queue.

The deal pointer arrives through the additive migration 0110_timeline_engagement.sql, which adds a nullable customer_timeline.engagement_id column and an IF NOT EXISTS index; ensureSchema probes for the column and adds it for a database bootstrapped before the migration ran. There is no backfill: a synced message carries no deal, so no existing row can be assigned one after the fact, and channel needed none because it is joined from the source item at read time rather than stored on the row.

Relationship edits refuse stale writes

PATCH /crm/customers/:id and PATCH /crm/contacts/:id accept an optional expected_updated_at body field: the record's updated_at as the client read it when editing began (a positive integer, milliseconds). When it no longer matches the stored record — a second writer changed it in between — the destructive overwrite is refused with 409 {"error":"stale_write","current":{...}}, where current is the organisation-scoped server record at that moment, so a client can show the comparison and reapply explicitly against the fresh baseline. A malformed value (non-integer or non-positive) writes nothing and returns 400 invalid_expected_updated_at. When the field is absent the shipped last-write-wins behaviour is unchanged for every existing caller; the ownership-change expected_revision precondition is independent and unchanged. No migration is involved, and a foreign-organisation id keeps answering the same 404 not_found whether or not the field is sent.

Customer type changes on the timeline

PATCH /crm/customers/:id now also appends one customer_timeline row of type customer_type_change whenever the request actually moves customer_type, written in the same D1 batch as the column so the change can never land without its record. The row carries the caller as actor_email and metadata of {"from": "<key>|null", "to": "<key>|null"} — organisation catalogue keys, never a rendered sentence, so each reader's client resolves the labels in its own language. A request that sets the type the customer already holds writes no row, and creating a customer with a type writes none either: creation is not a change. The same rule applies to the other write paths that can move the type (import commit, intake-batch commit). The row needs no migration — customer_timeline.type carries no CHECK constraint — and there is no backfill, so history before this release shows no type changes. It is returned by GET /crm/customers/:id/timeline, including under scope=customer-history. Changes to customer_class are out of scope and write nothing.

RAG benchmark surface (internal)

The knowledge retrieval benchmark (#4239) adds three staging-only evaluation endpoints: POST /knowledge/rag-bench/index with {"lane":"A"|"B"|"C","corpus_version":""}, POST /knowledge/rag-bench/query with {"lane","query","top_k"}, and POST /knowledge/rag-bench/reset with optional {"lane","corpus_version"}. All three require the Knowledge write level (representatives receive 403 forbidden) and are organisation-scoped from the signed-in session — no request field can select a tenant, and each organisation sees only its own benchmark rows. The lanes index only what the organisation's ready library files already store (the same passages the Sandbox reads); they never re-read stored binaries, and every model call goes through the governed Workers AI gateway with frozen model identities. A query answers with ordered chunk-level results carrying citation locators, snippets and model_usage counts, plus optional degraded/error fields naming any model stage that failed (recorded, never fatal). The endpoints build ekb_rag_* tables through additive migrations 0034_rag_bench_chunks.sql, 0035_rag_bench_embeddings.sql, 0036_rag_bench_graph.sql, 0037_rag_bench_builds.sql and 0038_rag_bench_chunk_progress.sql; they do not change shipped search behaviour and are internal evaluation surface, not a public product API.

Label value cleanup and exact-value filtering (#4226)

Organisations accumulate overlapping label values; two cleanup actions consolidate them. POST /crm/label-sets/{setId}/values/merge with {"target_value_id":"…","source_value_ids":["…"]} reassigns every customer carrying a source value onto the target value of the same set — a customer holding both ends up with exactly one target tag — then deletes the source values, and answers {"merged":{"customers":n,"values":m}} naming how many customers changed and how many values were retired. The whole effect is one atomic D1 batch: nothing mutates on a refusal. Refusals are stable: 400 target_in_sources when the target is among its own sources; 400 invalid_value_ids naming the offenders when target or sources are not values of this set under the caller's organisation (cross-set and cross-org ids alike); 400 invalid_entity_type on contact-entity sets (contact-value merge is a follow-up); 403 forbidden for callers without labels:write and for personal label sets, which v1 never merges; 404 not_found for a set id the caller cannot see. The protected Industry set participates normally — merging duplicate industry values is legitimate dedup, and each customer's industry derivation follows the surviving value.

PATCH /crm/label-values/{valueId}/category with {"category_id":"…"} moves one value to another category — within its set, or to another set of the same entity type — without touching any customer's or contact's tags (they reference the value row, whose id does not change), answering {"item":…} with the updated value. The directory's label-value filter options list the value under its new set and category immediately. Refusals: 400 protected_set when the value belongs to the protected Industry set and the destination category belongs to a different set (within-Industry category moves stay allowed); 400 invalid_entity_type when the destination category's set has another entity type; 403 forbidden for an organisation set the caller cannot manage; 404 not_found when the value or the destination category is not caller-visible — a foreign id reads as missing. Both ends of a move carry the same authority: a personal set's owner may reorganise within their own private sets, but moving a value into an organisation set — the protected Industry set included — needs labels:write exactly like the source set does.

The Customers directory narrows to exact values: GET /crm/customers?label_value=<id> returns customers carrying that value, several ids comma-separate (label_value=a,b — OR within the list) and compose with the existing label_set filter (AND). Values resolve only through label sets the caller can see, so an unknown, cross-organisation or another user's private value id simply matches nothing. To name consequences before cleanup, GET /crm/label-sets now attaches a per-value usage_count to every company-set value: the distinct number of customers carrying that value.

Connect a WeCom bot by QR code

When this connector is enabled, an organisation administrator can open Plugins → WeCom → Connect WeCom, then Continue to WeCom. A fresh official WeCom page opens. Scan with WeCom and approve the bot; keep Atlas open until it confirms Connected. Approval alone is not a successful connection: Atlas first verifies the bot subscription.

New bot-delivered text and voice transcripts enter the shared CRM inbox. The connecting administrator receives a named read/review grant; other members need an explicit account grant. Setup access alone does not grant message access. This route does not read unrelated conversations, historical archives or media, and cannot send messages. Unsupported message types are reported in account diagnostics without importing their content.

If the code expires or is cancelled, start again. Closing the dialog preserves the pending attempt in this browser tab; reopening resumes the status check. If another app takes over the bot connection, stop that app's connection before choosing Check connection. Pause stops imports; Resume reconnects. Disconnect revokes the saved bot credential and requires a new QR approval. Previously imported items remain under the same access rules.

REST contract

All three endpoints require a current organisation member with account-management authority. Platform support cannot perform consent. The organisation and initiating user are derived from the verified caller. Send an empty JSON object {}; unknown or foreign sessions return 404.

  • POST /crm/integrations/wecom/qr/start returns {session_id, authorize_url, expires_at}. expires_at is a Unix timestamp in milliseconds. Unconfigured environments return 503 connector_unavailable.
  • POST /crm/integrations/wecom/qr/:sessionId/poll returns {status, connection_id?, reason?}. Poll no more often than every three seconds. Status is pending, connecting, connected, expired, cancelled or failed. Retry the same session after an interrupted response; an already consumed session retains its original connection.
  • POST /crm/integrations/wecom/qr/:sessionId/cancel returns the same status shape. Cancellation only wins before consent is consumed; otherwise the original connection is returned.

Open only the returned official QR URL with noopener,noreferrer. Do not handle provider-window messages or log the QR URL. Bot secrets stay encrypted on the server and never appear in these responses. Existing versioned /crm/communications/connections/:id/test, /pause, /resume and /disconnect actions manage the account. Token-based /reconnect is refused for bot accounts.

Operators must configure the CRM credential key, R2 and WECOM_BOT_CONNECTIONS Durable Object binding, then enable WECOM_BOT_CONNECTOR_ENABLED for the accepted environment. The default is disabled. The outgoing WebSocket runs on Cloudflare Durable Objects and incurs active duration; alarms recover interrupted connections. This is separate from the legacy self-built application callback route below.

Receive WhatsApp text through Direct Meta

For an organisation owner or administrator setting up a manually managed Meta Cloud API account. This connector receives new one-to-one text messages. It does not send messages or import history, groups, media or delivery statuses. An account lookup alone does not prove that messages can arrive.

  1. Have the deployment operator record customer consent, ownership of the Meta app, WhatsApp business account (WABA) and business phone, the token's app binding, required grants and asset assignments, and proof that the app secret belongs to that app. Keep credentials and evidence in protected storage. A successful phone lookup is insufficient evidence for this approval.
  2. The operator provisions WHATSAPP_APPROVED_ACCOUNTS on the CRM Worker for the intended environment. Each JSON-array entry has org_id, app_id, waba_id, phone_number_id, approval_ref, expires_at (epoch milliseconds) and credential_sha256. The evidence reference identifies the reviewed proof; expiry requires renewed review. Missing, malformed or expired approval disables setup and callbacks. Duplicate phone claims disable the entire configuration.
  3. In Superpower → Plugins, open WhatsApp and connect the approved account. Supply all six fields: phone number ID, WABA ID, Meta app ID, access token, Meta app secret, and a separate webhook verify token. The token and secret values are sealed and are not returned. Connecting performs read-only provider checks; it does not subscribe the app or alter Meta configuration.
  4. In Meta, subscribe the approved app to the WABA and its messages field. Copy the callback address shown after connecting (also available on the account page) into Meta. Use the separate webhook verify token to verify the address. The callback is GET|POST /crm/integrations/whatsapp/callback/:connectionId.
  5. With customer authorisation, send a new test text to the business phone. Run the account check again and verify that the matching text appears in Atlas. Incoming messages become ready only after app subscription, callback verification and a signed message have all been observed for the current credential and configuration versions.

To calculate the approval fingerprint, use the repository helper with a protected JSON file containing org_id and a credential object with the six setup fields. This command prints only the digest:

bun -e 'import { whatsAppCredentialFingerprint } from "./services/crm/src/services/integration-whatsapp-access.ts"; const v=await Bun.stdin.json(); console.log(await whatsAppCredentialFingerprint(v.org_id,v.credential));' < /secure/whatsapp-account.json

Set credential_sha256 to that digest only after reviewing the corresponding credential proof. The canonical SHA-256 input is UTF-8 JSON of ["atlas-whatsapp-v1", org_id, app_id, waba_id, phone_number_id, access_token, app_secret, webhook_verify_token]. Preserve other approved accounts when updating the CRM secret. A credential rotation requires a new reviewed fingerprint, callback verification and test message. Account repair cannot change the phone, app or WABA identity; create a separate approved account for a different identity. A legacy phone-only account can be repaired with complete credentials for that same phone.

Staging operator: first approval

Use the supported publisher after the coordinator allocates a staging configuration window. It requires a clean staging checkout equal to fresh origin/staging, an approved platform-operator session in ATLAS_SESSION_COOKIE_FILE, and Cloudflare operator authentication.

bash scripts/deploy-staging.sh crm --whatsapp-approval /secure/entry.json --entry-sha256 ENTRY_SHA256 --manifest /secure/manifest.json --manifest-sha256 MANIFEST_SHA256

entry.json contains one reviewed approval object with the seven fields above. The manifest repeats that object as approval and records account_id, worker, environment, token_expires_at, window_start and window_end. Times use epoch milliseconds. Approval expiry cannot exceed token expiry or the allocated window. Keep provider credentials out of both inputs.

Review expected.deployment_id, expected.version_id, expected.parts (module names, SHA-256 digests and byte counts), expected.bindings, and expected.settings (the full Cloudflare settings response except bindings). Pin all seven staging commits in health: web, crm, agent, auth, tasks, ekb and governance. Cloudflare version annotations may change when the secret creates a version; runtime settings must remain identical, and the final snapshot pins the new annotations. Supply the SHA-256 of each reviewed file; edited inputs are refused.

This mode provisions WHATSAPP_APPROVED_ACCOUNTS only when absent. It refuses an existing allowlist rather than overwrite unknown approvals. It publishes a new CRM version from deployed code, verifies code, prior bindings, compatibility settings and unchanged service identities, and retains the reservation until final checks pass. No local Worker bundle is uploaded. After an unknown or failed outcome, stop and reconcile the retained reservation; do not retry or roll back automatically. Configuration success does not prove a real incoming message.

Check resultNext action
approval_requiredRenew the exact account and credential approval with the operator.
subscription_pendingSubscribe the approved app to this WABA in Meta.
callback_pendingVerify the current callback address and token in Meta.
message_pendingSend an authorised test text, confirm receipt and check again.
credential_rejected / provider_unreachableReview grants and account identity, or retry when Meta is reachable. Do not interpret these results as readiness.

POST verifies X-Hub-Signature-256 against the exact request bytes with the app secret, then checks both WABA and phone identity. Requests are limited to 256 KiB and 20 messages, entries or changes per notification; oversized batches and malformed text are refused as a whole before ingestion. Duplicate wamid deliveries create one item and retain its original message file. Other message types and status notifications are ignored. Invalid signatures return 401; account, approval or payload refusals return 403; invalid JSON returns 400; oversized bodies return 413; storage failures return 500 or 503 so Meta can retry. A valid accepted notification returns 200. POST /crm/communications/connections refuses generic WhatsApp creation with 400 provider_setup_required; use the guided provider endpoint. POST /crm/communications/ingest refuses WhatsApp connector-token submissions with 403 provider_callback_required.

Pause or disconnect stops new deliveries; disconnect revokes the stored credentials. Existing items remain. Resume or credential replacement invalidates the previous configuration's readiness evidence. Removing approval prevents activation and new requests; an already executing request uses its captured environment configuration until it completes, with its expiry and database revocation checked again at persistence. Provider access, customer consent and a live end-to-end receipt remain separate deployment acceptance prerequisites.

WeCom and WhatsApp connectable channels with provider webhooks (#4304)

GET /crm/integrations serves declarative setup documents for available providers. WhatsApp is coming-soon without current organisation approval; see Direct Meta setup. WeCom is the customer-owned self-built application route — fields corp_id (text) plus sealed corp_secret, callback_token and encoding_aes_key; WhatsApp is the Meta Cloud API direct route — phone_number_id, waba_id, app_id, plus sealed access_token, app_secret and webhook_verify_token. POST /crm/integrations/wecom/connect and POST /crm/integrations/whatsapp/connect (owner/admin manage, same body as the guided flow) verify the credentials against the provider BEFORE anything is written: WeCom by GET qyapi.weixin.qq.com/cgi-bin/gettoken with the corp pair, WhatsApp by GET graph.facebook.com/v23.0/{phone_number_id}, the WABA phone list and app subscriptions using the bearer access token. Only a verified answer creates the connection; a refusal answers 400 provider_verification_refused with a closed cause (credential_rejected or provider_unreachable) and, for a refused credential, the field to correct — fail-closed, so an unreachable provider also creates nothing, and no response ever repeats a submitted value. Connected accounts list, check, pause, disconnect and reconnect exactly like Email accounts; the account check re-runs the same provider call, reporting wecom:<cause> / whatsapp:<cause> detail tokens.

Each connection gets a public, provider-facing callback (no session, no connector token; the connection id in the path is the only webhook identifier): GET|POST /crm/integrations/wecom/callback/:connectionId and GET|POST /crm/integrations/whatsapp/callback/:connectionId. WeCom GET is URL verification: msg_signature must be the sha1 over Token+timestamp+nonce+encrypted echostr, the timestamp within 300s, the EncodingAESKey AES-256-CBC frame must decrypt, and the receive id must equal the connection corp id — only then is the decrypted plaintext echoed. WeCom POST carries msg_signature over the Encrypt body; a verified, decrypted text message ingests through the same normalization as POST /crm/communications/ingest (idempotent on WeCom MsgId; 200 success, 500 when the delivery quarantines). WhatsApp GET answers hub.challenge only for hub.mode=subscribe with the matching per-connection verify token (200 text/plain; 403 otherwise, the challenge is never echoed). WhatsApp POST requires X-Hub-Signature-256: sha256=<hex>, the HMAC-SHA256 of the raw body keyed with the Meta app_secret (never the webhook verify token); a signed notification whose WABA entry.id or metadata.phone_number_id names another account is refused (403) and text messages ingest idempotently on the wamid. A bad signature answers 401 (WhatsApp) / 403 (WeCom) and ingests nothing; a paused connection refuses; an unknown, malformed or disconnected connection id answers exactly like an unknown route, so a dead webhook confirms nothing. Every ingested item lands on the connection own organisation, resolved only through the per-connection webhook identifier — no provider payload is trusted before its signature validates.

Document review batches

The review page’s own surface (#4412, epic #4407). A batch is the files of one upload act, through any door; the rep reviews one file per page at /knowledge/file-review/<batchId>?file=k, and one Save per page files that one document.

POST /crm/document-review/batches takes {source, files:[{library_file_id, name, mime, file_kind?}]} where source is assistant, library or files_tab and file_kind is library (default, an EKB Library file) or customer_document. It answers 200 {batch:{id}, items:[{id, library_file_id}]} and writes nothing about any file’s customer. Refusals: 400 invalid_source, 400 files_required, 400 too_many_files (more than 50), 400 invalid_file, 400 invalid_file_kind.

GET /crm/document-review/batches/:id answers 200 {batch, items} with the items ordered gaps first and each one carrying its field set with origin marks. Every field has exactly one origin: doc read off the document, rule set by Atlas, you waiting for the rep. A field is marked doc only while its citation (reading block index and excerpt) still resolves against the file’s stored reading, which is re-checked on every read — a corrected or expired reading degrades the field to you and empty, carrying dropped: "citation_unresolved", rather than keeping a stale excerpt. A money field (amount, currency, received_amount) the reading does not state is always you and empty, carrying dropped: "money_not_stated"; it is never prefilled or badged from a guess, and rule is not available to it. An unknown or unauthorised batch answers 200 {batch: null, items: []}; 404 is reserved for a URL that identifies nothing.

PATCH /crm/document-review/batches/:id/items/:itemId/read is internal and body-signed (the next-action-mirror pattern), called by the asynchronous read job. Body {org_id (REQUIRED — the signed body is the only org scope the callback has), read_state, proposal_ref?} where read_state is reading, read or could_not_read. The organisation comes from the signed body and from nowhere else. Refusals: 403 forbidden unsigned, 400 invalid_request, 400 invalid_read_state, 503 authority_unavailable, and 404 not_found for another organisation’s batch or a page the rep has already filed — a read that lands late never reopens a saved page.

#4413 — the callback also carries extraction?: {document_text, raw, model, filename}, the reading the job produced: the text the model read and one cited candidate per field, validated against the blocks CRM divides that same text into. For a library item CRM builds the page’s cited DocumentProposal from it and stores it in customer_document_proposals keyed by the library file id, so proposal_ref: "doc:<library_file_id>" resolves exactly as a customer document’s does. It is built with no customer — a file dropped in the assistant is read before anyone has said who it is for — so the document-sourced fields are cited and the Atlas-sourced extras (matched deal, duplicate warning) are absent rather than guessed. The reading is ignored for a customer_document item, whose own read already builds with that customer’s context. Without extraction an item settled read has nothing behind its handle, and GET /crm/document-review/batches/:id then reports that item as could_not_read with read_note: "reading_unavailable" rather than showing an empty page badged read. The signed body is accepted up to 64 000 characters, with document_text capped at 20 000.

POST /crm/document-review/batches/:id/items/:itemId/read-now is the Files-tab door’s own read (#4414), session-authenticated and client-initiated: the review page calls it for every customer_document item still at read_state: "reading" with no proposal_ref, at most four at a time — every such page gets its read, but a fifty-file batch does not start fifty simultaneous ones. It exists because a Files-tab upload had no reader at all — the asynchronous read job is enqueued only by the assistant’s file hand-off, which that door does not go through, so items sat reading with proposal_ref NULL indefinitely. It runs the CRM’s own document-proposal read (the same read the “From a file” door runs, against customer_document_proposals) inside the request — 60–90 s, because a Worker cuts detached work off at about thirty seconds — and then marks the item read with proposal_ref: "doc:<document_id>", or could_not_read with no ref when the read cannot be done. It never writes agent_document_read_jobs: that table is agent-owned. Idempotent three ways — a settled or filed page is answered with its current state and nothing is run, a proposal that already exists is REUSED rather than re-read, and a read another caller still has in flight (a reading proposal row younger than two minutes) answers 202 {item} still reading rather than starting a second one. There is no lease and no retry row because the work is request-scoped: an abandoned request leaves the item reading and the next open of the page starts it again. Answers 200 {item: {id, read_state, proposal_ref}}. Refusals: 409 not_a_customer_document for a Library item (its read belongs to the agent), 404 not_found for an unknown or unreadable batch, an unknown item, another organisation’s batch, a document whose customer the caller cannot see, or a page filed while the read was out, and 503 read_unavailable when the read left no proposal row behind. A document deleted from the Files tab takes its page with it (#4412 UAT, pass 2): DELETE /crm/documents/:id removes the document’s review item in the same write and renumbers the surviving pages of its batch, GET /crm/document-review/batches/:id omits any item whose customer_documents row is deleted, and read-now or save on such an item answers 404 not_found — the page shows “This document no longer exists.” instead of a form.

PATCH /crm/document-review/batches/:id/items/:itemId/fill is “Apply to the remaining N”. It takes {customer_answer?, customer_id?, deal_answer?, engagement_id?, pre_atlas_ref?}, FILLS that page and saves nothing — it writes no customer record at all, and the response echoes saved_at: null so a caller can see that for itself. A page whose own Save has been pressed answers 404 not_found: a fill never reopens a filed document. Refusals: 400 invalid_customer_answer, 400 customer_required, 400 invalid_customer, 400 invalid_deal_answer, 400 engagement_required.

Confirm a new customer (STORY-148). The page’s explicit Confirm customer sends {customer_answer: "customer", new_customer_name, deal_answer?, engagement_id?, pre_atlas_ref?} with no customer_id. This is the one fill that writes a customer: CRM creates it under the POST /crm/customers owner rule (a rep’s new customer is theirs), records the confirmation and links the unsaved page in one D1 batch, and answers 200 {item} with the new customer_id. It is idempotent per page and name (compared with case, spacing and punctuation ignored): a retry, a reload or a concurrent repeat returns the same customer and never creates a second. The document itself is still not saved. Refusals: 400 invalid_customer_name (blank or over 200 characters), 400 invalid_customer_answer (a name with any other answer, or with customer_id), 404 not_found (saved page, or a batch the caller cannot read), 409 confirmed_customer_unavailable (the customer this confirmation created has since been removed or is no longer visible; nothing is recreated).

POST /crm/document-review/batches/:id/items/:itemId/save is the one write. In one action it writes that file’s customer association (customer_library_file_links for a Library file — no bytes move, one record and many views — or the customer_documents row for a customer document), its deal answer, and, for a transaction document whose amount and currency are known, its Sales history row. It writes nothing for any other file in the batch, and before a file’s own Save nothing has been written for it. The body carries the page’s answers plus an optional transaction of the document fields including anything the rep CORRECTED; a corrected value wins over the reading and is stored without a doc mark, because the rep put it there. Every source, including Library files, accepts an optional document_kind correction. Supported kinds are quote, po, invoice, receipt, delivery_note, contract, spec, sample and other. Saved classification overrides extraction. A type-only change is saved; unknown kinds return 400 invalid_kind. Save never completes having written zero rows: a payload that would write nothing is refused with 400, and the response carries writes, the number of rows written. Changing an already-saved file’s customer MOVES the file and its Sales history row to the new customer and leaves nothing behind on the previous one. When the Save binds a po-kind document to a deal, the pending Won verification row is written synchronously and no model read is started or awaited — the read runs on the asynchronous resumable path and one that never lands settles as cut_off through the existing verification GET. Refusals: 400 customer_answer_required, 400 invalid_customer_answer, 400 customer_required, 400 invalid_customer, 400 deal_answer_required, 400 invalid_deal_answer, 400 engagement_required, 400 invalid_engagement, 400 pre_atlas_ref_too_long, 400 nothing_to_write, 400 invalid_transaction, 400 invalid_amount, 400 invalid_currency, 400 invalid_date, 400 invalid_status, 400 invalid_kind, 404 not_found.

Every path is organisation-scoped at the boundary and every read is scoped by organisation as well as by batch id. Schema: 0127_document_review_batch.sql creates document_review_batches and document_review_items, both CRM-owned, additive and re-applicable, with no foreign key to the file id because production EKB is a separate database (ADR 06) and the column is polymorphic by file_kind. The per-file row carries a lost_reason column, written null here, so the lost-association story needs no further migration.

Per-file ordering and marks. Each item carries a frozen 1-based position: the page order is assigned in upload order at creation and re-sequenced gaps first exactly once — on the first read after every file’s read has settled and before anything has been saved — then never again, so ?file=k keeps meaning the same file while a rep works. The dots still reflect live state. Each item also carries deal_seed, non-null only for a deal the reading cites whose citation still resolves against the current reading (the Deal field pre-selects it marked doc and Save is live; a deal is never seeded for being the customer’s only open one), customer_mark — doc when the read matched, page when the uploading door fixed it, you while unanswered, and null for the rep’s own answer, which carries no mark — and proposed_new_customer_name, the buyer the reading named that Atlas has no customer for, which is what the seeded New-customer form starts from. #4413 adds proposed_customer_names — every company the reading named on the buying side that Atlas has no customer for, each offered as its own “New customer: <name>” option on the Customer picker, so an invoice billed to two companies puts the choice to the rep instead of discarding both names (proposed_new_customer_name is the first of them) — and read_note, one of reading_unavailable, buyer_is_another_company, nothing_readable or null, which is why a settled read put no values on the page. A page that says nothing cannot be told apart from a broken one.

Authorization is per caller, not only per organisation. Every item’s source must be visible to the caller at create time (a customer document through its customer, a Library file through the member who answered it or the customer it is linked to), or the batch is refused with 400 invalid_file before it exists. GET withholds customer_name, deals and deal_seed for a customer the caller may not see, returning the file and its fields without them. POST …/save checks the source document’s current customer as well as the chosen one, answering 404 not_found otherwise, so the review surface can never re-file another member’s document.

Save answers 200 {item, writes, sales_history_written, amount_pending}. writes counts rows actually changed and is never zero for an accepted Save; sales_history_written and amount_pending are what let a caller say “filed, with its Sales history row” or “filed, no Sales history row until the amount is entered” rather than leaving “filed” to imply a row that was never written. An identical re-save of an already-filed page is refused with 400 nothing_changed: re-running every writer as a no-op update and answering 200 told a rep their document had been filed twice. Changing a customer document’s customer moves the document row and its Sales history row together in one D1 batch, and clears the deal answer unless the chosen deal belongs to the new customer.

GET /crm/document-review/items/for-file?document_id=<id> or ?library_file_id=<id> — exactly one of the two, because the ids live in different stores — answers 200 {item: {batch_id, item_id, position}}, or 200 {item: null}. It is how a surface holding only a file id opens that file’s review page: My Docs’ “Edit”, and the “add amount” cell on a blank amount. Naming both ids or neither is 400 invalid_request. A file with no review page, another organisation’s file, and a file this caller may not see all answer {item: null} — the same answer on purpose, because a response that told them apart would be an existence oracle for another member’s documents.

A batch is not readable by the whole organisation. It is readable by the member who created it, or by a member who can see EVERY item’s source — “every”, not “any”, because a batch is one upload act whose pages are read as a set. Holding a drb_ id is not authorization: without this a colleague could read a personal My Docs file’s own fields, its reference, date and amount, which the per-item withholding above does not cover. The same rule gates the batch GET (which answers 200 {batch: null, items: []}), the fill and save routes (404 not_found) and the for-file lookup ({item: null}).

The page order is settled by the read callback, not by the GET, which is read-only. When the last UNSAVED file’s read lands, the unsaved items are re-sequenced gaps first among the positions the saved ones are not using; a saved file keeps its position, and a saved file whose read never landed does not hold the batch in upload order. It settles once per batch.

Lost association (#4416). Deleting a customer (DELETE /crm/customers/:id) or a deal (DELETE /crm/engagements/:id) no longer leaves the files filed against it pointing at something that is gone. After a successful delete — a refused one flips nothing — every affected file keeps its bytes and its previously-read values, loses only the association that went, and carries the reason on document_review_items.lost_reason: JSON {kind, name, removed_by, removed_at}, which the review page reads as “Was Nordlicht Outdoor GmbH, deleted 2 Oct by [email protected]”. A file with no review page of its own is given one in a per-member, per-day batch whose source is lost_association; the GET returns that value but POST /crm/document-review/batches never accepts it, still answering 400 invalid_source for anything but the three doors. A deal delete clears engagement_id and deal_answer on the file and leaves its customer answer alone. A customer delete blanks the Library file’s customer_library_file_links answer — customer_id, deal_answer, engagement_id and pre_atlas_ref to null — and keeps the row, so its stored read (read_json) survives for the file that is about to be asked about again. state is left as it was rather than moved to not_customer_related, which would be Atlas deciding on the rep’s behalf; a row with a null customer_id reads as unanswered everywhere, because a reader calls a row answered only on state = not_customer_related or a set customer_id. The file itself stays in EKB and still appears in My Docs. A created page is titled with the file’s own name, resolved through EKB for a Library file (CRM stores none) and falling back to the file id when EKB cannot answer; its read_state is read whenever the file has ever been read and could_not_read only when it has not. A soft-deleted file is neither flipped nor given a page. A customer_documents row still goes with its customer, because its customer_id is NOT NULL against atlas_customers. Saving any answer, including “Not customer-related”, clears lost_reason — a deliberate “Not customer-related” is an answer, not a loss — so the outstanding set is exactly lost_reason IS NOT NULL AND saved_at IS NULL.

GET /crm/internal/document-review/needs-you?org_id=<org> is internal and URL-signed (#4415, epic #4407), called by the agent Worker’s five-minute trigger sweep — which reads the three document tables ONLY through this route and never queries them, a deliberate departure from the other triggers beside it. The organisation comes from the signed URL; the signature covers the whole URL, so org_id cannot be swapped. now=<epoch-ms> names the caller’s own clock so one sweep pass counts every organisation against one instant. It answers 200 {members:[{member, counts:{unsaved, blank_money, lost_association}, total, first:{batch_id, index}|null, lost:{kind, name}|null}], truncated}, plus uploader — a SECOND candidate assignee, whoever dropped the files, when that is not member. It exists because member is the customer’s current owner and an owner email is not a membership check: a departed rep is still named on the customer’s row, and the Tasks pillar refuses a non-member assignee, so without a second candidate that member’s files would be surfaced to nobody on every tick. The agent sends both and the Tasks pillar’s own membership check picks, exactly as the retired per-file task already did, where index is the file’s own stored review position — the page number ?file=k addresses — read off the row and never recomputed. Two modes, and the difference is a safety property. Discovery (no member) lists members with something outstanding, at most 50, and sets truncated: true when the list was cut; a caller may raise from it but must never treat an absence in it as zero. Named (member=<email>, repeatable up to 50) answers about exactly those members and returns a row for each, total: 0 included; that explicit zero is the only thing that closes a daily task, which is why the sweep asks about the members it is closing by name rather than inferring a zero from a missing row. The three causes are disjoint so total is a count of FILES and the reason lines on the task cannot add up to more than its title: a file that lost its customer or deal counts as lost_association even though it is also unsaved. unsaved spans a review page whose own Save has not been pressed, a customer_documents row with no deal answer, and a customer_library_file_links row saved to a customer with no deal answer — a file with a review item is counted through that item and never a second time. blank_money is a filed po or invoice review page with no Sales history row, which is exactly the gap Save leaves when the document states no amount. A soft-deleted document drops out of every cause — a file that is no longer there is nobody’s outstanding work — and lost_association uses the lost-association story’s own predicate, lost_reason IS NOT NULL AND saved_at IS NULL. Each cause applies only after the same one hour of grace the per-file task used. Counting is applied at rep scope for every member: a file whose customer the member does not own, or whose customer has been pooled or archived, is not counted for them, because the task must never name a file its assignee cannot open. Refusals: 403 forbidden unsigned, 400 invalid_request with no org_id, 503 authority_unavailable. The task this feeds is keyed trigger:doc-review:<member>:<utc-day> and carries its own deep link in metadata; trigger:doc-answer:<document_id> is no longer raised, and an open one closes under its own existing sweep.

Document product lines: GET adds optional document_kind and product_lines to each item. A line has id and seven resolved fields: description, part_number, customer_part_number, internal_part_number, quantity, unit_price, line_amount. Each field carries value, origin, citation, how and dropped; unknown values stay null. The neutral part number does not identify whose code it is. Customer and internal codes require explicit source support.

The existing Save body accepts optional product_lines: at most 100 lines, each with an id and those seven keys as string or null values. Quantity and unit price use nonnegative decimal strings with at most six fractional digits; line amount allows twelve. Do not send numbers or exponent notation. Omit the array to preserve saved lines; send [] to clear them; null or malformed lines return 400 invalid_product_lines. Partial lines do not prevent filing. Sources are resolved by the server: unchanged source fields retain citations; edits are marked you. Saved lines belong to the organisation and file, survive reopening in another batch, and remain behind the existing file-visibility boundary.

Product subtotal is calculated separately from the document total using exact decimal arithmetic; missing amounts leave it unknown. Tax, discounts and currency are not inferred. Quotation product saves do not create financial transactions, including when a legacy client supplies transaction amounts; quotation reviews omit payment status and received amount. Existing PO/invoice transaction handling remains separate. Older proposals without product lines remain readable, and an older client that omits lines cannot erase them.

For an unsaved review with no saved product snapshot, GET can recover previously missing numeric fields from a clearly headed product table whose code, description and three ordered numeric cells were split into consecutive text blocks. The result cites the actual numeric block. This is read-only and makes no AI call. Repeated identities, conflicting values, mismatched rows, partial numbers or an incomplete table stay unresolved. Saved empty lists and cleared fields remain authoritative; Save preserves the recovered citations when values are unchanged.

Document-review Note: Save accepts optional top-level note (a trimmed string of at most 200 characters, or null). Omit it to preserve the prior Note; null explicitly clears it, including any original sourced Note. Invalid types or longer text return 400 invalid_note. A nonnull legacy transaction.note is accepted when top-level Note is absent; the top-level property takes precedence. Saved Notes return with you origin and no citation, are scoped to the organisation and source file, and survive another review batch. Untouched sourced Notes keep their original provenance. Note saves create no financial transaction or Communications entry.

A product description may wrap across consecutive text blocks; quantity and prices still require a complete table row identified by its unique part number. A wrapped description citation revalidates every continuation fragment; missing or changed fragments invalidate it. Recovery also covers numeric values rejected by the older row-layout validator, without replacing saved human values.

For quotations, Save accepts an optional quotation_fields object containing any of occurred_at, reference, amount and currency. Omitted keys preserve the prior value; an explicit null clears only that field. For example, {"quotation_fields":{"reference":"QUO-2","amount":null}} changes the reference and clears the amount while preserving date and currency. These fields are saved as a file-scoped quotation snapshot independently of sales transactions. User corrections and explicit nulls are authoritative on reopen and in the Quotation tab. Legacy transaction quotation corrections apply only non-null values; a quotation_fields key takes precedence. Unchanged sourced values keep their citations. Identical re-saves return 400 nothing_changed and preserve the saved timestamp. A patch on a non-quotation returns 400 quotation_fields_not_applicable. The snapshot and the item’s saved marker are written in one D1 batch. Re-filing uses the file’s current customer and deal answer.

Custom categories: Save accepts document_category, a trimmed name up to 80 characters with document_kind: "other". Omission preserves the saved category; null clears it. Names are reused case-insensitively within the company account. GET batch adds custom_categories; items add document_category. Category creation and confirmed classification share the saved-item D1 batch. Non-quotations accept review_fields with optional occurred_at, reference, amount, currency; omitted keys preserve values and null clears. These are document metadata; Library files and custom categories create no financial transaction.

GET /crm/customers/:id/reviewed-documents returns {items: [...]} for saved files, with file_id, file_kind, name, document_kind, document_category, fields, saved_at and current deal answers. Organization, customer visibility and live source permissions apply. Empty or unauthorized collections return 200 {items: []}. Reclassification moves the file between document-type views; file amounts do not add to sales or payment totals. Earlier saved files use their available document values when no saved snapshot exists.

Measure Knowledge activation impact

This staging operator census returns counts, fixed reason codes and digests. It does not activate Knowledge pages. Use a live tenant session with current knowledge:write and knowledgeFiles:read permissions. Platform sessions, delegated tokens and caller-supplied organisations are refused. Auth refreshes authority before reading and before returning results.

GET /knowledge/census?family=page&limit=1
GET /knowledge/census?family=page&cursor=OPAQUE_CURSOR
GET /knowledge/census?family=impact
bun scripts/knowledge-census.ts --expected-sha EXACT_STAGING_SHA --sessions PRIVATE_JSON_FILE

The private JSON file maps each tenant ID to a local cookie-file path. Each cookie file contains only its tenant's atlas_session=... Cookie header. Keep both files private. The command uses existing Cloudflare operator authentication and the approved bounded staging D1 reader to enumerate tenant IDs. Source text stays inside EKB. It refuses redirects and checks the exact staging EKB identity.

Families: page, answer, book, book_legacy, file_derived, legacy_file_derived and file_derived_history. Limit is fixed at 1. Encrypted cursors bind tenant, actor and family and expire after one hour; restart after expiry. Impact counts current published policies and workflows and records losses of operational passages, operational sandbox context, semantic search, derived metadata and related projections.

Response fields: schemaVersion, orgId, buildSha, buildRef, rolloutMode, family, total, scanned, counts, reasons, digest, nextCursor, impact. No titles, content, lineage or source IDs. The command reconciles totals and repeats the full inventory. Missing tenant authority or refused/incomplete sections produce complete:false; changed data produces drift:true. Neither earns an approval digest. Two matching passes are not an atomic snapshot: repeat immediately before activation.

Classification measures stored-owner lineage under reviewer permissions, not every user's access. Reconstructable content requires a new draft and review. Product must accept measured withholding and capability losses; otherwise keep R2 inactive until STRUCTURE021. Migration0041 retains replaced/deleted derivative history even while inactive. Core activation cannot return to inactive. This report grants no activation authority.

This administrator operator is an intentional REST/MCP/CLI parity exception: the repository command calls the same owner endpoint; no public MCP tool or Atlas catalogue action is added. Refusals: identity/permissions 401/403, invalid query/cursor 400, changed rollout 409, unavailable authority/storage/identity 503. Fix the prerequisite and restart; do not bypass it with content SQL. Source documentation does not establish deployment.

Impact also supports the encrypted cursor, with one current release pointer per page. Its total includes all current pointers; policy/workflow/other counts include only released entries on that page and are accumulated by the command. The digest binds exact pointer and release identity, revision, content bytes and operational/withdrawal state. Equal counts do not hide release replacement. A missing or mismatched release refuses the report.

Knowledge pages and source access

These interfaces are implemented but inactive by default. Exposure requires a complete target census and accepted Product impact review. This reference does not establish staging or production availability.

GET /knowledge/pages?limit=50
POST /knowledge/pages
GET /knowledge/pages/:id?view=approved
GET /knowledge/pages/:id/revisions
POST /knowledge/pages/:id/revisions
GET /knowledge/pages/:id/revisions/:revision
POST /knowledge/pages/:id/decisions

GET collections accept limit (default 50, maximum 100) and cursor. The page list also accepts view=approved|current, with no scope filter. Collections return {items,nextCursor}; cursors never identify withheld rows. Detail returns {page,revision}; a revision read returns {revision}. Creation and revision append return 201; decisions return 200.

PageCreate: {scope,title,content,sources,origin,answerId?,idempotencyKey}
PageUpdate: {expectedRevision,title,content,sources,idempotencyKey}
PageDecision: {expectedRevision,decision,reason?,idempotencyKey}
ObjectRef: {owner,kind,id,revision?}

scope is personal|company; origin is authored|saved_answer; decision is submit|approve|reject|retire. saved_answer requires an owner-held answerId: EKB derives exact text and complete lineage, refusing caller-forged or reduced sources. Editing approved content creates a draft while the prior approved revision remains readable. Review must match the current submitted revision. Governed REST actions use /assistant/v1/actions/<action>, never a direct Worker hostname.

ekb.page.list, ekb.page.read, ekb.page.create, ekb.page.update, ekb.page.decide, ekb.page.revisions.list, ekb.page.revisions.read.

Personal pages remain owner-only. Company contributors need current knowledgeFiles write permission; review needs knowledge write permission. Ordinary company readers see only the approved revision; view=current requires author or reviewer access. Every read and retry rechecks all sources. Withdrawn, corrected or unreadable sources withhold the entire derivative, including summaries, citations, related titles and counts. Legacy content without complete lineage and Sales-dependent derivatives remain unavailable. The core fallback uses current original passages for lexical retrieval; semantic-only search returns source_unavailable, and unverified vector ranking does not enter model context. Original file titles, passages, downloads and existing permissions are preserved.

Reads require ekb:read. Content-returning delegated writes require both explicitly granted ekb:read and ekb:write. The body idempotencyKey identifies the EKB operation; proposal and confirmation idempotency_key values are separate. Retry with the identical body and key. A 409 conflict requires reading and reviewing the current revision again. A 404 source_unavailable does not identify inaccessible sources; 503 read_unavailable means the read could not be established. Internal adapters may use the signed receiptOnly=true query to recover an existing receipt only; an absent receipt returns 404 and never creates an operation.

Internal Service Binding readiness: GET /ekb/internal/knowledge-pages/readiness requires a current signed principal for audience=ekb and action=ekb.page.readiness; ordinary cookies or Bearer credentials do not admit it. It returns only {schemaVersion:1,enabled:boolean}, with no tenant content. Discovery and proposal admission check this state on each request; EKB rechecks at execution.

Semantic search coverage and retry (STORY-157)

GET /knowledge/files/:id adds semantic_coverage to a ready file’s item, for every file type: {"state", "embedded", "total"}. total is the file’s passage count and embedded the passages stored for semantic search. state is pending (waiting for its embedding job, including a file just published), partial (only embedded of total passages are covered), failed (the job failed for the current version), complete (covered) or none (the file holds no searchable text). Drafts, processing and failed files omit the field; the confirm, retry, re-extract and update (PATCH) replies carry it whenever they return a ready file. The read uses the same visibility as the detail itself: a file the caller may not see answers 404 and its coverage is never revealed. A spreadsheet’s xlsx_index.coverage is unchanged.

POST /knowledge/library/files/:id/embedding/retry (no body) re-queues a failed file’s embedding job for its current version. Administrators only: any other role receives 403 and nothing changes. A file the caller may not see, or that does not exist, is 404. It is accepted only when semantic_coverage.state is failed; otherwise 409 with error not_failed (or wrong_status for a file that is not ready). Success is 200 {"semantic_coverage": {"state": "pending", "embedded", "total"}}; the job then runs on the queue and the detail read shows complete or partial once it finishes. 503 queue_unavailable means the job could not be sent; the file stays pending and the five-minute backfill sends it again within the hour. This is a session (web) route; it has no MCP tool or CLI command.

Sources first: the two-step sandbox answer (STORY-155)

POST /knowledge/sandbox/ask takes the same request and, by default, returns the same single JSON answer as before. A caller that wants the documents found before the answer is written sends Accept: application/x-ndjson. The response is then 200 with content-type: application/x-ndjson; charset=utf-8, one JSON object per line:

  1. {"step":"sources","sources":[{"file_id","file_name","passage_index","snippet","sheet"?,"range"?}],"scope":{…}} — written as soon as recall finishes, before the model writes the answer. Each source is a passage the answer will be written from, in the same shape as closest; sheet and range ride only on a spreadsheet row.
  2. {"step":"answer",…,"server_timing":"scope;dur=…, …"} — the exact body the JSON response carries, plus the stage timings that the Server-Timing header carries on a JSON response (a header cannot follow a body that has started).

If the answer fails after the sources line (for example 409 source_invalidated), the final line is {"step":"error","status":409,"error":"source_invalidated",…} — never a partial answer. An ask that ends before recall writes only its final line, under the status the JSON response would have: a refusal such as 400 invalid_request is {"step":"error","status":400,…}, and a spreadsheet route or an empty scope is one {"step":"answer",…} line with the Server-Timing header. A stream that ends without a final line was cut off: keep the sources and ask again. The assistant, the MCP tools and the CLI do not send this header and are unchanged. This is a session (web) route option; there is no MCP tool or CLI command for it.

Review a customer spreadsheet

A single XLSX or CSV with explicit company and contact headers uploaded to the Assistant opens customer batch review. In a contact directory, 市场 maps to company.main_markets; 类型 (for example 品牌商 or 商超连锁) is preserved as company.background business information. It does not set the lifecycle customer_type.

PATCH /crm/intake-batches/:id/review accepts {version, changes: {candidate_id: {corrections, cleared_fields, match_choice, append_contacts, selected, skipped, reviewed}}}. Corrections use proposal field paths. An empty correction removes that override; cleared_fields is the complete array of fields explicitly left blank. append_contacts: true appends approved contacts to an existing customer, preserving its primary contact and skipping an existing email. Approve the contact name and each optional field you want to copy through corrections. Existing company fields change only when explicitly approved.

Matching is rechecked after edits and before commit, within the caller's customer scope. A match requires an explicit match_choice: {customer_id, choice: "update"}, "create_separately". A changed match returns invalid_match_choice or candidate_changed: refresh, compare the details and decide again. Review alone creates no customer or contact. Confirm import to call POST /crm/intake-batches/:id/commit with the current version, selected candidate IDs, reviewed fingerprints and an idempotency key. Open a resulting customer link to inspect Main markets, Background and Contacts.

reviewed: true records Save and confirm independently of selection. Subsequent field or match edits reset it unless explicitly reconfirmed. Import includes all remaining valid rows; unreviewed badges become red before the user may choose Import anyway. Unresolved names and duplicate choices still block import. Selection alone never marks a row reviewed.

Invoice status and rolling-window recovery

GET /governance/platform/billing is a platform-superadmin read. Optional orgId selects an organisation. Returned invoices, payments, allocations and allocation reversals stay bounded to 500 rows each; audit events stay bounded to 1000. Each invoice status uses its full organisation-scoped allocation, reversal and effective write-off history at the read time, independently of those list limits. Future-effective write-offs and activity after the read time do not affect that status. No additional pagination or response fields are introduced.

GET /governance/billing/windows requires billing read to show the signed-in account; billing write additionally shows up to 200 organisation ledger actors. The account windows sum recorded nonnull customer_credits across that email's organisations, including rated usage whose reservation is still pending. Existing tier selection and admission rules apply. Each window returns {usedCredits,limitCredits,resetAt}. When a window reaches its limit, resetAt is the earliest millisecond that enough usage has expired to fall strictly below the limit, assuming no new recorded usage. The inclusive cutoff requires one millisecond after the relevant usage timestamp plus the window duration. Below the limit it is the next positive usage expiry. With no positive usage, no tier, or a zero limit, resetAt:null supplies no automatic recovery promise.

The internal Service Binding route POST /governance/internal/credit/reservations can return 409 account_window_exceeded with {window,usedCredits,limitCredits,resetAt}. This is not a public client route. The reason still names the first blocking window (5-hour first), while resetAt waits for all blocking account windows to clear; if any cannot recover through expiry, it is null. With null recovery, ask an administrator to review the account limit. New usage can move the estimate; organisation, wallet and other limits may still block a call.

Billing and Assistant display recovery rounded upward to the shown minute, so a displayed “from” time never precedes the API timestamp. The next usage expiry is shown as a “by” time.

Workbench tasks (Tasks v2)

Release 1 of Tasks v2 gives each person one Workbench feed and replaces the noisy automatic triggers with four rules: gone quiet (TT-01), deals that need a next step (TT-03), customers missing required details (TT-07) and the existing daily file review. The agent raises and closes the tasks; CRM computes what is owed from the organisation's settings; the web reads one feed. Every route is organisation-scoped from the session or the signed URL, never from a body or query value, and every list answers 200 with empty collections when there is nothing to show.

Workbench feed

GET /crm/workbench/feed?done=today|week (session; any member) returns {rows, reach_out_more, suggestions, housekeeping} for the caller's own work. The response also carries reply_within_hours, the organisation's reply window from Settings › Tasks (a reply row whose email has waited longer reads Overdue; absent if that setting could not be read), and a reply row's email.triage carries asked_by: {at, quote} when the customer's email asks for something by a stated date (at = ms epoch, the last millisecond of that day in the organisation's time zone, or the exact time when one is stated; quote = the email's own words), else null. rows are the caller's tasks, read from the Tasks pillar with the caller's own session: every open task, plus tasks closed since UTC midnight (done=today, the default) or in the last seven days (done=week). Each row is {task_id, kind, type, section, status, title_i18n, reason_i18n?, params, customer, stake, due_at, route, closed_at, closed_by}. kind is reply, gone_quiet, deals_next_step, missing_details, doc_review or task; section is reply, coming_up, reach_out or housekeeping. title_i18n and reason_i18n carry {en, zh}, one language per string; params carries the raw inputs (customer_name, count, days_since, interval_days). An open count row shows the live count. stake is {open_deal, class_rank}; rows are ordered by stake or due date, never by elapsed time. customer is null when the caller cannot see the customer. A row whose task names a deal also carries deal:{id,title} (null otherwise), and a task that names only a deal takes that deal's customer. housekeeping is {deals_next_step, missing_details, doc_review}, counted live. suggestions is always []: the Workbench Suggestions section was retired on 2026-10-12 and the field stays only so older clients still parse the feed (GET /crm/suggestions is unchanged). reach_out_more is deprecated and always 0: since 2026-10-15 every customer who needs reaching out gets a task (no cap of five) and the Workbench pages them. A reply row answering a synced email also carries email; when the organization has Inbox sorting on, email.triage is {topic, urgent, suspicious}, the same answers the Inbox shows for that email (the Workbench groups the row by them): topic is inquiry, quotation, sample, purchase_order, invoice_payment, logistics, after_sales, supplier, promotions, job_application, notice or other, or null when the email is not sorted yet; urgent is true at urgency 4 or 5; suspicious is the suspicious answer. With Inbox sorting off, triage is omitted.

Deals that need a next step

GET /crm/deals/needs-next-step (session) returns {items, by_stage} for the caller's own open deals: not in a won or lost stage, on an active, assigned, non-Lost customer, with an empty Next action and no future waiting date. Each item is {deal_id, title, customer:{id,name}, stage, gap, proposal}. gap is {kind:"inquiry_missing", missing, known} for Inquiry (only product, quantity and timeline without a current needs fact), {kind:"no_quote_or_sample"} for Needs confirmed with no sent quotation and no shipped sample, and {kind:"no_rule"} for every other stage, including custom stages. proposal is a template, never AI: {text_i18n, completion_condition, source} with communication_logged for an Inquiry ask and quotation_sent for a quotation; null when the person writes the step. Use creates the task through POST /tasks with that completion condition. At most 100 items are described; by_stage counts all.

PUT /crm/opportunities/:id/waiting takes {until, reason} (epoch milliseconds in the future, at most 366 days ahead; a non-empty reason of at most 500 characters) or null to clear. The deal leaves the count until that time; GET /crm/engagements/:id returns waiting_until and waiting_reason. A rep may set it only on a deal of a customer they own; managers and admins may set it on any deal in their organisation. An unknown, foreign or invisible deal answers 404 not_found; a bad body answers 400 invalid_waiting_until, 400 invalid_waiting_reason or 400 invalid_body. Migration 0142_engagement_waiting_until.sql adds both columns.

Customers missing required details

GET /crm/customers/missing-details (session) returns {required, items}: the caller's own active customers missing at least one detail the organisation requires, at most 200. Each item is {customer_id, name, missing, values, suggestions}. The details are contact_channel (a primary contact with a phone or email), country_market (country or main markets), product_interest (products, or a confirmed intent or preference fact) and class (a class other than lead). suggestions come only from existing pending facts, as {value, source_label} where source_label is file, note, ai or manual; nothing is written until the person saves through the ordinary customer and contact routes. When the organisation requires no details, required and items are empty. The response also carries class_assignable: false when the organisation's class list has nothing but Unranked (lead) and Lost, in which case class is left out of required and never counted, because no one could fill it. GET /crm/settings/tasks returns the same class_assignable so Settings can explain why; the saved setting itself is unchanged.

Files that need you

GET /crm/workbench/files (session; any member) returns {items, truncated}: one item per file behind the caller's own "N files still need you" count, oldest first, at most 500 (truncated says the list was cut; the count never is). The list and the count read one predicate, so they always agree: an unsaved file, a filed PO or invoice with no Sales history row, or a file whose customer or deal was deleted, after a one-hour grace, counted for the customer's current owner (or whoever dropped it when it has no customer), never for a pooled or archived customer or a deleted file. Each item is {id, cause, batch_id, item_id, index, file_kind, file_id, name, mime, created_at, saved_at, customer, customer_answer, deal_answer, lost}. cause is unsaved, blank_money or lost_association; lost is {kind, name, removed_at} for the last. batch_id, item_id and index address the file's review page (/knowledge/file-review/<batch_id>?file=<index>) and are null for a file filed before review batches existed. Nothing is written: answers are saved through the review page's own POST /crm/document-review/batches/:id/items/:item_id/save. Nothing outstanding answers 200 {items: [], truncated: false}.

Open and retire a file (STORY-189). POST /crm/workbench/files/open with {id}, a row id from the list, answers which review page Review opens. A file that already has one gets it back unchanged: 200 {state:"ready", batch_id, item_id}. A file filed before review batches existed (a doc: or link: row) gets a one-file page created the way an upload creates one, with its stored customer answer carried over and its original age kept, so the count neither drops nor doubles; opening the same row again answers the same page. A customer document's page starts reading on the page's own POST /crm/document-review/batches/:id/items/:item_id/read-now, which reuses a stored reading. When the original can no longer be read (its stored bytes are missing, or the Library file is gone for the member who dropped it) the answer is 200 {state:"gone"} and nothing is created. POST /crm/workbench/files/retire with {id} takes such a file off the list and the count without changing any answer, and answers 200 {retired:true}; the page is marked retired (migration 0147_document_review_item_retired.sql). Retire answers 409 original_readable while the original can still be read and 503 original_unchecked when it could not be checked. Both act only on the caller's own list: another member's or organisation's row, or one already done, is 404 not_found; a missing id is 400 invalid_request.

Task settings

GET /crm/settings/tasks (any member) returns {config:{reply_within_hours, required_customer_details}, can_edit, updated_at}. Until the first save it reads the defaults: 24 hours and ["contact_channel","country_market","product_interest","class"], with updated_at:null. PUT /crm/settings/tasks takes the same config fields and needs the pipeline write capability, the same gate as the activity standard (403 forbidden otherwise). It refuses rather than clamps: 400 invalid_reply_within_hours (whole hours 1 to 168), 400 invalid_required_customer_details or 400 invalid_body. An empty details list switches the missing-details rule off. A required detail also stops the profile_gap_ suggestion for the same field.

Scheduled tasks

GET /crm/settings/scheduled-tasks (session; admin, the Sales & CRM settings gate: a rep gets 403 forbidden) returns {config, can_edit, updated_at, classes:[{key, label}], today}. config is {holiday_greetings:{enabled, holidays:{<holiday>:{on, instruction}}, classes, china_only, days_ahead}, reminders:[{id, enabled, title, instructions, date, repeat, for, created_by, customers}]}: the China public holidays (new_year, spring_festival, qingming, labour_day, dragon_boat, mid_autumn, national_day), days_ahead 3, 5, 8 or 14, repeat once, week, month or year, for me or every_rep, and customers {mode:"none"}, {mode:"all"} or {mode:"classes", classes}. classes is the organisation's customer classes without Lost. Until the first save it reads the defaults with updated_at:null; a read never writes. PUT /crm/settings/scheduled-tasks takes {config, expected_updated_at?} and answers the same shape. It refuses rather than clamps (400 with the validator's code such as invalid_days_ahead or invalid_reminder_date), and when expected_updated_at (the value loaded; null when no row existed) is not the stored one it answers 409 stale_settings and saves nothing. A reminder sent without created_by is stamped with the saver. The sweep reads the schedule and the customers of a run through signed internal Service Binding routes, GET /crm/internal/task-rules/scheduled-settings?org_id=<org> and GET /crm/internal/task-rules/scheduled-customers?org_id=<org>&classes=<csv|all>&china_only=0|1[&member=<email>], which are not public: active, assigned, non-Lost customers with an owner, at most 200 per member and 100 members, with truncated when cut. A scheduled run is one task per rep per run; the Workbench lists it under Scheduled (kind:"scheduled", section coming_up, plus scheduled:{rule, holiday, customer_count}). The task page uses the session routes GET /agent/assistant/scheduled-tasks/:task_id (the task's assignee only; {task_id, rule, holiday, title, instruction, occurrence, due_at, status, items:[{customer_id, name, contact, email, class_key, status, draft}]}), POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/draft with {lang:"en"|"zh-CN", redraft?} (writes the one customer's draft now), PATCH /agent/assistant/scheduled-tasks/:task_id/items/:customer_id with {subject, body}, POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/sent (I sent it: logs an email_sent contact on that customer) and .../skip, both answering {item, task_closed} (the task closes when every customer is sent or skipped), GET /agent/assistant/scheduled-tasks/:task_id/sender-projection and POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/send (Send from Atlas, one confirmed send per customer; see Scheduled tasks below), and POST /agent/assistant/scheduled-tasks/:task_id/done for a reminder with no customers. POST /agent/assistant/scheduled-tasks/assess (admin) with {title, instructions, customers} answers {needs_customers, advice, suggested} before a reminder is saved; if the model is unavailable it answers needs_customers:false and advice:null. Nothing is ever sent automatically: a rep sends each draft with Send (from Atlas, confirmed) or from their own email and taps I sent it.

Counted contact

With the activity standard's email_received switch on (absent means on), a received customer email counts as contact: the newest inbound email from a person (not automated, not failed_check) that someone linked to the customer through POST /crm/inbox/links/confirm or POST /crm/inbox/links/confirm-exact. When such a link is newer than the customer's last contact, CRM moves Last contact forward and tells Tasks a communication was logged, which closes the customer's open gone-quiet task with closed_by event:communication_logged:email. POST /crm/customers/:id/timeline now also returns the new entry's id, 201 {ok:true, id}, and a logged contact tells Tasks its kind, so a task closed by it reads, for example, event:communication_logged:email_sent.

Whose move (release 1b)

Release 1b replaces "deals need a next step" with whose move it is. Every customer conversation is either our move (the customer is waiting on us) or their move (we are waiting on the customer), decided by the latest counted contact on any channel: a received email linked to the customer, or an inbound message, is our move; a sent email (including I sent it) or an outbound message is their move; a call, meeting or note follows its Who's next answer. POST /crm/customers/:id/timeline accepts an optional next_move on a call, meeting or note: "me" (the customer is waiting on us) or "customer" (we are waiting on them); leaving it out reads as their move. Any other value, or next_move on another type, answers 400 invalid_next_move and writes nothing. With "me" the entry still moves Last contact but does not tell Tasks a communication was logged, so the reply task it raises stays open. An email_sent may carry "scheduled": true: an email sent for a scheduled task (a holiday greeting or an admin reminder). It moves Last contact, so gone quiet counts it, but it is not counted for whose move and does not tell Tasks a communication was logged, so an open Waiting for your reply task stays open. scheduled on another type, or a value other than a boolean, answers 400 invalid_scheduled and writes nothing. An entry may also carry an optional thread_key, the email thread it answered (one email thread is one conversation): I sent it on a reply task for an email thread sends it, and Tasks then closes that thread's Waiting for your reply task only, never another thread's for the same customer. Nothing is stored; an entry without it keeps closing the customer's waiting tasks as before. A value that is not a non-empty string of at most 200 characters answers 400 invalid_thread_key and writes nothing. Migration 0144_timeline_next_move.sql adds the column, and GET /crm/customers/:id/timeline returns it on every entry as next_move (null when not said). Our move older than the organisation's reply window raises a Waiting for your reply task; their move older than the class interval is the only case that raises gone quiet; a customer never contacted, with an open deal or still a lead, gets a Make first contact task in Reach out. The deals-need-a-next-step count is retired: housekeeping.deals_next_step is always 0 and no feed row carries kind deals_next_step; the field and GET /crm/deals/needs-next-step stay for older clients. Feed rows of kind reply carry params.channel (email, message, call, meeting or note) and params.trigger_contact {channel, at, from, subject, snippet}; rows of kind first_contact sit in reach_out. Every row with a customer carries deal:{id,title,stage}, the task's own deal or the customer's furthest open deal, and stage_gap_i18n, the one-line step to the next stage (for example "To reach Evaluation: send the quote or ship a sample."), or null. A task made with the New task form's email-a-customer option (metadata.launcher assistant_email) also carries request:{note, at}, the person's note to Atlas (at most 500 characters) and when the task was created (epoch ms, or null); no other row has it. The agent reads these through the signed internal routes GET /crm/internal/task-rules/our-move, GET /crm/internal/task-rules/contact and GET /crm/internal/task-rules/stage-gap.

Agent sweep counts (internal)

Internal Service Binding routes for the agent's task sweep; public clients cannot use them. Both are signed over the whole URL and take the organisation only from it: GET /crm/internal/task-rules/gone-quiet?org_id=<org> and GET /crm/internal/task-rules/counts?org_id=<org>, each with optional member=<email> (repeatable) and now=<epoch-ms>. Gone quiet returns {members:[{member, customers, more}], truncated}; each customer is {customer_id, name, class_key, interval_days, days_since, spell_at, has_open_deal}, quiet when days since the last counted contact exceed the class interval in the current activity standard, top five by stake (open deal first, then class A to C). Counts return {members:[{member, deals_next_step:{total, by_stage}, missing_details:{total}}], truncated, complete}. Without member the answer lists at most 50 members and sets truncated when cut; with member every named member gets a row, zeros included, which is the only answer a task may be closed on. complete:false means a count failed and closes nothing. Errors: 400 invalid_request, 403 forbidden, 503 authority_unavailable. Task keys: trigger:gone-quiet:<customer>:<spell_at>, trigger:deals-next-step:<member>:<utc-day>, trigger:missing-details:<member>:<utc-day>.

Tasks pillar

GET /tasks accepts closed_after=<epoch-ms> for tasks closed at or after that instant (the Done pill). POST /tasks/admin/tasks-v2-reset?dry_run=1&created_before=<epoch-ms>&cursor= is a one-off staging operation for a platform superadmin session and is refused in production. It cancels open tasks created before the cut-off or raised by a retired trigger, 200 per page, with closed_by reset:tasks-v2, and returns {dry_run, by_org:[{org_id, by_source, count}], remaining, cursor}; repeat with dry_run=0 and the returned cursor until remaining is 0. A count task closed by the agent reads agent:task_triggers#count_zero, #superseded or #not_owed.

Workbench Reply all

The read-only GET /agent/assistant/task-drafts/:task_id/reply-all previews {cc: string[]} using the source mailbox for legacy clients. Workbench sends POST /agent/assistant/task-drafts/:task_id/reply-all with exactly {sender_connection_id} for the selected sending mailbox. The caller’s live sender projection must contain that exact connection in ready state; unavailable, inactive, foreign or Atlas-fallback selection returns 409 reply_all_unavailable without recipients. Malformed or extra body fields return 400 invalid_request. The source remains the exact answered email visible to the caller, with an unsent ready reply draft and matching source sender/To. Only visible To/Cc participate; Bcc is never read. The selected sending address and primary recipient are excluded, with domain-insensitive deduplication; other source addresses remain visible. Sender readiness and address are rechecked after awaits; source, owner and draft fences are retained. The preview does not edit the draft: review it and use the existing recipient PATCH snapshot before Send. Source or draft changes refuse with 404 not_found or 409 conflict. No customer-address lookup, aliases or Reply-To overrides are inferred.

Send a new email from the Inbox

The Inbox New message window sends a hand-written email with no Workbench task. GET /agent/assistant/compose/sender returns the signed-in person’s sender projection ({state:"accounts", accounts, default_connection_id} or {state:"none"}); 503 sender_unavailable when it cannot be read. POST /agent/assistant/compose/send takes exactly {request_id, to, cc?, bcc?, subject, body, sender_connection_id?, expected_from_address, override_reason?}: one bare to address, bare cc/bcc lists, a non-empty single-line subject and a non-empty body; anything else is 400 invalid_request. Both routes belong to the Inbox: while the organisation’s inbox feature is closed they answer 404 not_found. The send runs through the same governed path as a Workbench draft — policy admission, a sender binding of exactly these words, CRM’s durable send command — and its sent copy appears in Sent. Policy admission sees the customers the email goes to: every To, Cc and Bcc address is matched to the organisation’s customers through their contacts’ exact email addresses, and admission runs once for each customer matched (once with no customer when none is), so a rule on a customer class applies exactly as it does to a Workbench draft for that customer; any refusal (for example 409 policy_blocked) refuses the send. When Atlas cannot establish those customers, nothing is sent: 503 recipients_unverified. Only a connected mailbox the caller may send from is used: the Atlas fallback address, an inactive mailbox, another person’s mailbox or a different address than expected_from_address answer 409 approval_refused before anything leaves. The same request_id confirmed again sends at most once and answers the first attempt’s outcome; the same request_id with different words after a send was attempted answers 409 request_reused and sends nothing. 200 ({outbox, command_id}) is the only delivery claim; 503 send_uncertain, 409 send_in_flight and 502 send_failed mean check Sent before writing it again. No Tasks task, customer timeline entry or Today draft is created, and the prepared row is never served through /prepared/:id; CRM reads its envelope only as its author.

Your email signature

One plain-text signature per person, set in Settings → Personal. GET /agent/assistant/signature returns the signed-in person’s own {signature, signature_zh}: signature is the English/default one and signature_zh the Chinese one (each null when not set). PUT /agent/assistant/signature takes {signature?: string | null, signature_zh?: string | null} with at least one of the two; a field left out is unchanged, and if either field is invalid nothing is changed. Line endings are normalised, trailing spaces and blank lines at either end are dropped, and an empty text or null removes that signature; the answer is the stored {signature, signature_zh}. A signature is at most 600 characters and 12 lines; longer, or a body with neither signature nor signature_zh, is 400 invalid_request; 503 signature_unavailable when it cannot be stored. The organisation and person come only from the session, so nobody can read or change another person’s signature. When a person has one, AI reply drafts end with it, exactly as written (a Chinese draft ends with signature_zh, falling back to signature when no Chinese signature is set; an English draft always uses signature), in place of Atlas’s own Best Regards, / 此致敬礼 sign-off (the model’s own closing is removed), and changing the sending mailbox through POST /agent/assistant/task-drafts/:task_id/sender leaves it as it is; the Inbox New message window starts with the one matching the interface language, with the same fallback. Without one, drafts keep the fixed sign-off and the sender’s name.

Review a source-pinned reply recipient

GET /crm/inbox/items/:id/reply-source reads one authorized inbound RFC822 source under the Inbox feature, current Auth member role, mailbox read_review, customer-owner visibility and existing no-draft policy. The private no-store result has source (item_id, connection_id, content_hash), from, to and review_required; it contains no raw message, storage key or Bcc. Absent Reply-To uses From; a single valid differing same-domain Reply-To requires explicit review. Cross-domain mismatch, repeated/malformed/ambiguous headers and unavailable originals refuse. Missing/unreadable sources return 404 item_not_found; invalid or changed sources return 409 source_mime_invalid, 409 source_changed or 409 source_policy. Use an external email client when existing policy cannot support this source.

GET /agent/assistant/task-drafts/:task_id/reply-target returns {target, revision} for the exact unsent reply draft. POST /agent/assistant/task-drafts/:task_id/reply-target accepts exactly {source, expected_revision} and derives To itself; arbitrary To is refused with 400 invalid_request. Explicit Use Reply-To confirms the displayed original From and destination; Send remains separate. Stale review returns 409 conflict or 409 reply_source_changed; missing review returns 409 reply_review_required. Reply all uses the reviewed primary. Local parts remain case-sensitive, domains case-insensitive, and aliases are never guessed.

Reply approval binding v4 pins reply_source in its digest, separately from outgoing actor reply_to and Forward forward_source. Approval, retry, dispatch and live owner lookup recheck source, owner and current authority. The signed owner request forwards the verified actor for attribution only. Only verified-mailbox CRM command transport supports v4 and rechecks immediately before DATA; Atlas fallback and legacy SMTP refuse it. Existing v2/v3 canonical bytes and transport remain unchanged. Current Auth account-authority adds the member role; Reply, Forward and attachment source readers fail closed if it is absent or support-only. Deploy Auth and the compatible Agent owner route before CRM. No sender scopes are added.

Existing one-composer recipient editing remains available for customer-linked replies. An explicit PATCH /agent/assistant/task-drafts/:task_id To edit includes {to, expected_reply_revision}, using draft.reply_review.revision from the saved authorized draft. The server records exact manual To, actor, customer and original-source intent; clients cannot supply a manual-mode flag. draft.reply_review.manual_to identifies this separate choice; canonical From/Reply-To and their header-review flags stay unchanged. Body-only Save omits To and cannot accept Reply-To or create manual intent. Missing proof/revision returns 409 reply_review_required; stale revision returns 409 conflict. Source changes, revoked read/owner authority and blocked source policy still refuse. Approval, retry and the final pre-DATA owner lookup match the manual recipient and intent to the fresh owned Agent draft; v4 already pins the actual To and source, with unchanged v2/v3 bytes. An unlinked reply cannot use manual intent. Same-source word edits and redraft preserve explicit choice; source changes invalidate it. Explicit Use Reply-To or Use original sender clears manual intent and restores the canonical recipient. A differing manual primary disables automatic Reply all with 409 reply_all_manual_recipient; restoring the source-derived primary re-enables it. No aliases or new send permissions are inferred.

GET /internal/mail-reply-task?org_id=&task_id= is a signed, organization-scoped Tasks Service Binding lookup for current open reply assignment and canonical inbound identity. All source-bound v4 replies require a known owned Task bridge, the exact saved Task source and actual nullable customer snapshot, fresh Tasks checks and fresh Agent intent. Tasks metadata cannot retarget a contradictory parsed source; canonical c:customer fallback remains supported separately from the raw nullable customer. A linked Task without an account field does not authorize an account: CRM independently fences the exact source mailbox. Initial recipient resolution failure terminates Send/retry before policy admission or approval mutation. Deploy the Tasks lookup before Agent; a missing lookup fails closed. Ordinary v2 nonmail/outreach remains unchanged.

Manual Forward drafts

POST /agent/assistant/forward-tasks with {source_item_id,request_id} creates a distinct private, self-assigned manual Forward from an email the session caller may currently read, including an already answered email. Use a new UUID request_id for a new intent; keep that identity for retrying the same creation. A retry recovers the same task; reusing it with another source answers 409 conflict. The response is {task_id,item,draft}: 201 when created, 200 on recovery. No model runs. The Task title and metadata contain no source mail details.

The saved draft uses basis:manual_forward, empty paragraphs, existing to, cc, bcc, subject, and forward:{source:{item_id,connection_id,content_hash},body,attachments,available_attachments}. The initial To is blank, Cc/Bcc are empty, and all supported source files are selected. The complete plaintext quote includes From/To/Cc/subject/date/body; structured Bcc is omitted. Each attachment carries {id,version,content_hash,part_id,filename,size_bytes}; no bytes or storage locations leave CRM. available_attachments retains the immutable manifest so a removed file may be selected again.

The existing PATCH /agent/assistant/task-drafts/:task_id accepts Forward-only to, body, attachments:[{id,version,content_hash}], plus subject/Cc/Bcc. Every Forward edit requires expected_forward:{to,subject,body,cc,bcc,attachments} matching the complete previous saved state; stale edits answer 409 conflict and retain the saved draft. A blank To may be saved while editing; Send needs one bare mailbox. The body is preserved exactly, without trimming or reply paragraph limits. Source/body over 1,000,000 UTF-8 bytes or serialized draft/combined approval payload over 1,500,000 bytes answers 409 forward_too_large without clipping.

POST /agent/assistant/task-drafts/:task_id/send requires the exact expected:{to,subject,body,from_address,cc,bcc,attachments,source}, including empty arrays, before explicit approval. Forward uses a connected sending mailbox and a source-bound v3 approval; existing reply/outreach v2 canonical bytes are unchanged. Source-read permission and destination-send permission are independent and checked again before SMTP DATA, even without attachments. Changed source or safety refuses; no automatic replacement or retry occurs. Attempted drafts lock and use the existing explicit prepared retry route. Forward emits no original In-Reply-To/References, customer contact/timeline event or original reply completion; it completes only its own task and retains an unlinked Atlas Sent copy. Redraft, sender re-signing and “I sent it” refuse 409 forward_manual_only. A missing/unreadable source is 404 not_found; canonical source safety is 409 source_policy with no_draft. GET /internal/mail-forward-task?org_id=&task_id= is a signed Tasks Service Binding authority lookup, not a public client endpoint.

Workbench Cc and Bcc

The existing PATCH /agent/assistant/task-drafts/:task_id also accepts cc and bcc arrays (up to 50 bare ASCII email addresses each). A recipient edit must include expected_recipients: {cc, bcc} matching the last saved lists; stale edits return 409 conflict. Invalid addresses or missing expectations return 400 invalid_request. The existing draft-owner and live source permissions still apply. Saved recipient lists survive reload and redrafting; no new draft store is created.

Send confirmation includes expected.cc and expected.bcc with the exact visible lists. Missing or changed lists on a draft with extra recipients return 409 draft_changed before dispatch. Prepared approval and retry compare all recipients as well as content. Bcc remains outside recipient-visible MIME and sent-copy headers. Atlas fallback and legacy SMTP refuse extra recipients with approval_required / recipient_transport_unavailable before provider submission; select a connected sending mailbox or remove the extra recipients. This candidate requires the approved-recipient transport deployment; source-derived Reply all, Forward and attachment selection are separate follow-on flows.

Agent drafts and Run now

POST /agent/assistant/task-drafts with {task_id} (session; the task's assignee) creates or returns the task's work item and draft, idempotently: {item, draft}, where draft is null until drafting starts. GET /agent/assistant/task-drafts/:task_id reads it; PATCH /agent/assistant/task-drafts/:task_id with {subject?, paragraphs?, to?} saves an edit (to is one bare email address, otherwise 400 invalid_recipient; for a reply not linked to a customer only the answered email's sender is allowed, otherwise 409 recipient_locked); POST /agent/assistant/task-drafts/:task_id/redraft writes a fresh draft. A draft is {to, subject, paragraphs:[{text, cite?}], basis, status, error?}; basis is thread or plain_checkin, status is drafting, ready or failed, and a cite names a deal, timeline entry, quotation, order or profile Atlas actually read. Drafting runs through the governed AI gateway and is attributed to the person who opened the task. POST /agent/assistant/task-drafts/:task_id/sent (I sent it, for a reply sent from the person's own mail app) logs an email_sent contact on the customer and returns {timeline_id, task_closed}. A first-contact, gone-quiet or next-step draft goes to a contact person of the customer who has an email (the primary contact first) and greets them by name. When no contact has an email, nothing is drafted and no model call is made: the draft is ready with needs_contact: true and an empty to and paragraphs, PATCH and send answer 409 needs_contact, and sent answers 409 no_recipient (as it does for any such draft with no recipient). Add the contact with POST /crm/contacts, then redraft. GET /agent/assistant/task-drafts/:task_id/sender-projection says who would send: {state:"none", from_address?} (the Atlas address, or nothing when Atlas cannot send) or {state:"accounts", accounts:[{state, connection_id, kind, from_address?}], default_connection_id}. POST /agent/assistant/task-drafts/:task_id/send with {sender_connection_id?, override_reason?, expected?:{subject, body, to, from_address?}} sends the ready draft from Atlas (when expected is given and differs from what the server holds, it answers 409 draft_changed or approval_refused with detail: sender_changed and sends nothing): the exact subject, text and recipient on screen are approved by the caller in that request, admitted as external communication, and dispatched through the connected mailbox (or the Atlas address) with reply threading; on sent it answers {item, draft, task_closed, policy?, override?}, with draft.send = {prepared_id, outcome, command_id?, outbox_id?, at}, one email_sent contact logged and the task closed by Tasks. Any other outcome answers the prepared-work send vocabulary (send_uncertain 503, send_failed 502, send_in_flight 409, approval_refused 409 with the sender refusal as detail, rate_limited 429) plus draft; once an attempt was made the draft is read-only (PATCH, redraft, sent and a second send answer 409 send_attempted with prepared_id) and the only way forward is POST /agent/prepared/:prepared_id/retry-send. Nothing is ever retried automatically. A send accepted through a connected mailbox is also kept as the outbound item of its conversation in the Inbox (comm_items, direction outbound, linked to the customer); the Atlas-address fallback has no mailbox and keeps no copy. A reply draft also carries asks:[{text, source|null, quote|null, covered_by, blank, status}] (at most 6): each request or question in the email it answers, the index of the paragraph that answers it, and status covered or needs_you; where the records cannot answer, the text holds a blank such as [Lead time for a January PO: please confirm] (blank), the ask stays needs_you until an edit fills it (a bracket that still holds the confirm words or starts with the blank's topic is still a blank), and send answers 409 placeholder_in_text while any blank remains. Each requested document is an ask too, with need (its index in needs_documents) and a status that follows the need (not_needed when marked so). A draft is signed with the display name of the mailbox it is sent from (the caller's default mailbox when drafted), else the account's name; POST /agent/assistant/task-drafts/:task_id/sender with {sender_connection_id: string|null} (the draft's owner; a mailbox from the caller's own sender projection, or null for none) re-signs it when the sending mailbox changes and answers {item, draft}: only the sign-off's name line changes, and a sign-off the person edited is left as it is (404 unknown_sender, 503 sender_unavailable). When the reply needs a document (a quotation, proforma invoice, spec sheet, certificate, catalogue, invoice or packing list the customer asked for or the reply promises), the draft carries needs_documents:[{kind, label, source:{kind, id, label, at?}|null, quote|null, status, placeholder, document?}] (at most 3) and its text holds a visible blank such as [attach quotation]; Atlas never creates the document. PATCH /agent/assistant/task-drafts/:task_id/needs/:index (the draft's owner) with {status:"attached", document_ref:{kind:"customer_document"|"library_file"|"upload", id, name}, placeholder, kind}, {status:"not_needed", placeholder, kind} or {status:"open", placeholder, kind} (Remove) resolves one need and answers {item, draft}; placeholder and kind are the need as the window showed it, and 409 need_changed answers when they no longer match. An attached file replaces only its blank with "the attached <label> (<file name>)", the file name taken (cleaned) from the document's own record; not_needed removes the sentence holding the blank, and open puts back whatever the need changed. Two needs of one kind have distinct blanks ([attach quotation 2]). A customer document must belong to the task's customer and a Library file must be readable by the caller, otherwise 403 document_forbidden and nothing changes. An upload ref names a file the person attached from their computer and must be one of the draft's own uploads (otherwise 409 upload_not_attached); its name comes from the draft. A need answered this way does not lock send while that file is on the draft (the email carries it), and removing the file from uploads reopens the need with its blank back in the text. send answers 409 documents_unresolved while any need is open, 409 attachments_unsupported when a file is attached, and 409 placeholder_in_text while any [attach …] blank remains in the subject or text (Send from Atlas does not carry attachments yet: copy the draft and send it from your own email). A send body with send_without_document: true is the person's explicit confirmation to send without those documents, in one call: it waives only documents_unresolved and attachments_unsupported (the email leaves without the file) and takes out the sentence holding each open need's own [attach …] blank, the same cut not_needed makes (a sentence that also holds another need's blank stays). The needs keep their status, their labels are recorded on the send as draft.send.without_documents, and the draft's text becomes the words that left. expected, when given, is the draft as stored before the cut. Every other check still runs after the cut: any other blank answers 409 placeholder_in_text, and draft_changed, sender and policy refusals are unchanged; a refused send changes nothing on the draft. Without it, or with false or null, the refusals above are unchanged; any other value is 400 invalid_request. sent accepts {documents?:{"<index>":"attached"|"not_needed"}} for needs still open and logs each resolved need on the contact ("Attached: Quotation"). POST /agent/assistant/reply-tasks with {comm_item_id} (session) raises, finds or reopens the one reply task for that email's thread — linked to a customer or not (STORY-194) — and answers {task_id, created, reopened} (201 when raised). When that email was already answered — its reply sent from Atlas by this task or by the sweep's, or the thread already reads as answered for it — the answered task stays closed as history and another reply is raised, like Gmail: a new task (201, created: true) whose draft is written by the governed AI gateway like any reply (basis thread or thread_only, status drafting then ready), with the reply's recipient, subject and threading. The model is told the thread was already answered and reads our earlier sent reply, so it drafts a follow-up to that reply rather than answering the same email again; anything the records do not hold is left as a blank for the person to fill before sending. redraft writes a fresh follow-up. A draft stored before this change may still have basis manual_reply (the person's own words, starting with only their signature): it still loads and sends, send answers 409 draft_empty until something besides that signature is written, and redraft replaces it with an AI-drafted follow-up. It closes when it is sent, or as superseded once a newer email in the thread is waiting for a reply (that email then gets its own drafted task). Asked again after a follow-up was closed unsent, the call reopens the newest such follow-up with a fresh draft (200, reopened: true) instead of raising another. The caller must be able to read the thread's mailbox (otherwise 404 not_found, as for another organisation's email). A linked thread goes to the customer's owner while they can read the mailbox (anyone else gets 403 not_owner with owner_email); an unlinked thread, or one whose owner cannot read the mailbox, goes to the caller. Only one reply task is open per thread: if another person holds it, 409 assigned_elsewhere with assignee_email; 409 not_person for automated or unverified mail; 400 invalid_request; 503 crm_unavailable or tasks_unavailable. The sweep raises the same task on its own for every thread Atlas sorted as needing a reply (key reply:<thread_key>:comm-<latest inbound id>), to the customer's owner when they can read the mailbox, else to the mailbox's owner; the reply window only marks it overdue. GET /agent/assistant/reply-tasks/:task_id/assignees (session; a person who can read the thread) answers {assignees:[{email}]}, the people who can read the thread's mailbox; 404 not_found for a task that is not an email-thread reply task or a caller who cannot read it. POST /agent/assistant/reply-tasks/:task_id/assign with {assignee_email} (session; the current assignee only) hands an open task to a colleague and answers {task_id, assignee_email}; 400 invalid_request, 403 not_assignee, 404 not_found, and 409 task_closed, cannot_read_mailbox (the target cannot read that mailbox) or assignee_outside_org. The task closes with the close reason answered when a complete read shows the thread answered (a later outbound email or a "Mark as replied"); a missing or partial read closes nothing. A draft for a thread not linked to a customer is grounded on the email thread alone and leaves prices, lead times and quantities as […: please confirm] gaps; a failed sender check, a different reply-to address, changed payment details or a suspicious flag raises the task with no_draft set to sender_check_failed, reply_to_mismatch, payment_change or suspicious and no draft. The signed CRM and Tasks reads behind these routes are internal Service Binding interfaces and are not public. POST /agent/assistant/task-triggers/run (owner or admin; own organisation; refused in production) runs the task sweep for the caller's organisation now; running it twice raises nothing new. POST /agent/admin/work-items-v2-reset?dry_run=1&created_before=<epoch-ms>&cursor= is a one-off staging operation for a platform superadmin session and is refused in production or when the environment is unset. It drops open work items created before the cut-off that have no task or whose source is not a v2 trigger task (a v2 trigger task's work item is kept and closes with its task), 200 per page, with status_reason reset for tasks v2, and returns {dry_run, by_org:[{org_id, by_type, count}], remaining, cursor}; repeat with dry_run=0 and the returned cursor until remaining is 0.

Scheduled tasks (holiday greetings and reminders)

An admin sets dates in Settings › Customer management › Scheduled tasks. Once a day per organisation (again the same day only when the settings change), the agent's sweep raises one task per rep per run through the Tasks pillar, with the key trigger:scheduled:<rule>:<occurrence>:<member> (<rule> is holiday.<id> or reminder.<id>), completion_condition rep_confirms and metadata.scheduled = {rule, holiday, reminder_id, occurrence, customer_count, instruction}. A holiday task appears days_ahead days before the festival and is due the day before; a reminder appears 7 days before its date and is due on it. A failed or truncated customer read raises nothing for the members it may have cut and is retried on the next tick; the sweep never closes anything. Nothing is ever sent automatically.

The routes below are session routes for the task's assignee only (someone else's task, or a task without a scheduled key, answers 404 not_found). GET /agent/assistant/scheduled-tasks/:task_id?lang=en|zh-CN answers {task_id, rule, holiday, title, instruction, occurrence, due_at, status, items:[{customer_id, name, contact, email, class_key, status, draft, send?}]}; an item's status is pending, drafting, ready, failed, sent or skipped, and draft is {subject, body, language, why} or null. POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/draft with {lang, redraft?} writes the customer's draft through the governed AI gateway, attributed to the caller, and answers the item (a ready draft is returned as it is unless redraft is true; a model failure answers the item as failed). The admin's instruction reaches the model as untrusted content after Atlas's fixed rules (the contact's language, at most one real thing from the customer's records, no invented facts, prices, dates, discounts or promises, the rep's name only in the sign-off). PATCH /agent/assistant/scheduled-tasks/:task_id/items/:customer_id with {subject, body} saves the rep's edit (409 drafting, not_editable or send_attempted). GET /agent/assistant/scheduled-tasks/:task_id/sender-projection answers who would send for the caller, the same shape as the task-draft sender projection ({state:"none", from_address?} or {state:"accounts", accounts, default_connection_id}; 503 sender_unavailable). POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/send with {sender_connection_id?, override_reason?, expected?:{subject, body, to, from_address?}} (Send, confirmed in a dialog) sends that one customer's draft from the rep's mailbox, or the Atlas address, through the same approve → send pipeline as a task draft, to the address shown on the item. An item's email and contact are read live: the contact person ordinary outreach writes to (the primary contact with an email, else the earliest), else the company's own email with no name, else null (Send then answers 409 no_recipient); the draft greets that contact. Nothing is dispatched unless the task is open and the item is ready with a draft, not sent, skipped or already attempted, with a valid email. Refusals: 409 draft_not_ready, already_sent, skipped, send_attempted, no_recipient, recipient_changed (the draft was written for another recipient; redraft it), no_subject, draft_empty, placeholder_in_text, draft_changed (the item no longer holds what the window showed), sending (another click or tab is sending it) or task_closed, each with the current item; 403 customer_unreadable (a rep's customer reassigned since the run); 400 invalid_request; 503 send_unavailable; and the sender's own refusals such as 409 approval_refused. On sent it answers {item, task_closed, policy?, override?}, with item.status sent and item.send = {prepared_id, outcome, command_id?, outbox_id?, at}. Any other outcome answers the sender's error JSON (error, detail, command_id) plus item with its send recorded: the draft stays locked (PATCH and a redraft answer 409 send_attempted with prepared_id) until the explicit retry on POST /agent/prepared/:prepared_id/retry-send, and the next GET shows the item sent once that retry lands. Skip and I sent it stay available. A greeting sent from Atlas is logged exactly like I sent it: one email_sent timeline entry marked scheduled, Last contact moves, and the customer's open reply or reach-out tasks are not closed by it. POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/sent (I sent it, from the rep's own email) logs one email_sent contact through POST /crm/customers/:id/timeline and answers {item, task_closed}; a second click logs nothing. POST /agent/assistant/scheduled-tasks/:task_id/items/:customer_id/skip answers the same shape. The task closes when every customer is sent or skipped (closed_by agent:scheduled_tasks). POST /agent/assistant/scheduled-tasks/:task_id/done closes a reminder with no customers and answers {task_closed:true} (409 has_customers otherwise). Refusals: 409 task_closed or conflict, 503 crm_unavailable or tasks_unavailable. POST /agent/assistant/scheduled-tasks/assess (admin or owner) with {title, instructions, customers, lang?} answers {needs_customers, advice, suggested}: when the instructions ask Atlas to write to customers but customers.mode is none, needs_customers is true with a short recommendation and a suggested customers choice; on any AI failure it answers {needs_customers:false, advice:null, suggested:null}, so a save is never blocked. POST /agent/assistant/task-triggers/run also runs this sweep and accepts {today?:"YYYY-MM-DD"} to simulate a day (outside production); its answer carries scheduled.

Bounded automation test sessions

Bounded automation test sessions: custom broker, activation fields, scope and rollback / 自定义代理、启用字段、范围和回退。

Switch previously authenticated accounts

Open the organisation menu → Accounts to choose an account already authenticated on this browser. The selector is separate from organisation memberships. Up to four accounts are remembered; adding another replaces the oldest saved account. Add account opens the existing email OTP or Google sign-in. Verification is still required. Before continuing, finish unsent work or choose to discard it. The transition closes other Atlas tabs' workspaces, retires pending requests and streams, removes local drafts and workspace state, and opens a neutral route. Saved conversations and identity-scoped preferences remain stored and are read only for the verified account. Nothing is sent automatically. Browser Web Locks and HTTPS are required; unsupported browsers cannot switch accounts.

GET /auth/accounts is a cookie-authenticated, read-only first request. It returns {items:[{id,email,name,orgId,current}]}, including only browser-held sessions that remain unexpired, unrevoked and entitled. An empty vault returns 200 {items:[]}. No credentials appear in JSON. The browser stores up to four host-only HttpOnly, SameSite=Lax cookies, with Secure on HTTPS; account tokens never enter JavaScript storage.

ActionRequestConfirmed result
SwitchPOST /auth/accounts/switch with {accountId} from the current listActivates that existing session after strict live role, permission, membership and organisation checks; preserves its expiry. The old source session is atomically replaced in the vault so concurrent switches from it cannot both win.
AddPOST /auth/accounts/add with {}Retains the current account in the vault, ends its active browser context and permits ordinary /login. Cancelling or failing sign-in grants no new identity.
Sign out currentPOST /auth/accounts/logout with {}Revokes and forgets the current browser-held account while retaining the others. The sign-in page can reopen another saved account.
Sign out this browserPOST /auth/accounts/logout-all with {}Revokes every held browser session and clears the active and saved-account cookies. This is separate from POST /auth/sessions/revoke-all, which signs the current email out on every device.

All writes require Content-Type: application/json and an exact public Origin; there is no delegated-token or email-based login-as API. A supplied x-atlas-account-context header is JSON [email,orgId]; a stale context is refused rather than applying an old tab's request to another account. Successful writes return {ok:true}. Refusals include 400 invalid_request, 403 forbidden, 403 account_unavailable, 409 account_conflict, and 503 account_storage_unavailable. Every response is Cache-Control: no-store. A failure never claims account activation; reopen sign-in to resolve the actual session. Current human OTP/Google sign-in and the native POST /auth/logout flow remain available. Test sessions never enter this human vault and use their separate grant-scoped selector.

简体中文

Assistant conversation review (candidate, gated support rollout)

In Ask Atlas, open View diagnostics for your conversation, then choose Load redacted conversation to inspect the available visible messages. Loading is explicit; no conversation search, bulk export or new transcript store is provided. This reference describes the candidate source; it does not establish staging or production availability.

GET /agent/assistant/threads/:id/diagnostics returns {diagnostics, policy, grants, audit, attribution, window}. GET /agent/assistant/threads/:id/review returns {review, policy, attribution, window} for the verified conversation owner, including ordinary members. Organisation administrator status does not permit another member's transcript. Ownership, tenant and current mailbox-source permissions are checked server-side on each page and again before response. Responses use Cache-Control: no-store.

The review contains allowlisted visible user and assistant text, run/sequence/timestamp evidence and closed tool-status metadata. Internal planning and chain-of-thought, tool inputs/results, attachment bodies and imported prior turns are omitted. Known secret patterns are conservatively masked in this response after text fragments are assembled; redacted text remains private. This does not guarantee detection of every secret or change the existing canonical private history. outcome: unknown is deliberate: end_turn establishes an ended reply, never successful completion of the user's goal. Sequence gaps can reflect reservations and do not establish data loss.

Reads use the existing canonical session log, at most 10 pages of 500 events, a 10-second route budget and 2,000,000 source bytes. Review text is bounded to 32,000 characters per message and 256,000 projected response bytes; diagnostics has a separate 1,000,000 response-byte cap. Work limits, missing openings, partial reads, expired read-window content and unknown retained history remain visible as coverage limitations. A failed source or authorization check returns no partial private result.

POST /agent/assistant/threads/:id/support-grants requires a same-origin browser request, application/json body of at most 2,048 bytes, and exact fields {reviewerEmail, expiresInMinutes, scope?, noticeAccepted:true}. The reviewer email must be normalized to lowercase; expiry is a whole 5–1440 minutes. scope is metadata by default or explicitly transcript; one never implies the other. The owner must accept the scope-specific notice and confirm the named reviewer and expiry. At most ten active scoped grants exist. DELETE /agent/assistant/threads/:id/support-grants?reviewerEmail=reviewer%40example.test&scope=transcript revokes exactly that scope and remains available while rollout is disabled. Both mutations answer 204; deletion of the conversation removes its grants and local access records.

In Platform labels, Support conversation review discovers indexed conversations with an active exact transcript grant naming the current support reviewer. Enter an organisation, optionally an owner email, local updated-from/through times and an observed attempt state, then choose Find authorized conversations. Use Previous page/Continue search and select Review conversation; message text still requires the separate explicit load action. Owner history continues to use the existing private conversation history.

GET /agent/platform/assistant-reviews requires orgId; optional ownerEmail, from and to (epoch-millisecond decimal timestamps within the configured read window, never future), outcome and opaque cursor filter the read. The ordered index scan follows ten fixed slots, each considering at most ten candidates (100 candidates per bounded search). Slots 1–9 always return a fixed-size opaque continuation token, including when no rows are authorized or indexed; only fixed slot 10 returns a null cursor. Continuation is padded on this fixed schedule so it does not disclose denied index existence. Empty slots prove neither absence nor the existence of denied conversations; reaching the scan limit does not establish a complete inventory. It returns {items, nextCursor, coverage} with items limited to {threadId, orgId, ownerEmail, updatedAt, outcome} and coverage:{source:"retained_snapshot_index",complete:false}. No titles, bodies, inferred intents or global totals are included. The index holds at most 20 snapshots per owner; orphaned canonical logs and pruned snapshots are absent. The encrypted cursor is scope-bound and does not expose denied candidates. Each returned item still requires current exact transcript consent, enabled support transcript policy, live superadmin authority, no mailbox-derived sources, and acknowledged durable access audit. A roster-only policy does not grant list access. outcome is the latest observed attempt state (answered, failed, cancelled, interrupted, awaiting_tool, running or unknown), never a claim about user-goal success. Unauthorized list reads return the same 200 {items:[]} as an empty authorized page.

Platform labels also accepts exact organisation ID, conversation ID and owner email; it never lists all users' conversations. GET /agent/platform/assistant-diagnostics?orgId=ORG&threadId=THREAD&ownerEmail=OWNER reads metadata; GET /agent/platform/assistant-review?orgId=ORG&threadId=THREAD&ownerEmail=OWNER reads redacted messages. Each requires live superadmin authority, the exact unexpired owner grant for that scope, enabled approved policy, refusal of all mailbox-derived support sources, and an acknowledged durable Governance access audit before any content returns. A session-label review permission alone grants neither route.

Default ASSISTANT_DIAGNOSTICS_POLICY has windowDays:30, supportReviewEnabled:false, transcriptReviewEnabled:false, purgeEnabled:false. The read window accepts 1–90 days; it is a visibility limit and does not erase historical data. No physical timed retention or historical purge is activated. Metadata activation requires supportApprovalReference; transcript activation separately requires transcriptApprovalReference and the metadata gate. Configuration is not approval: do not enable either support capability without a separately approved rollout scope. Named-authorized-superadmin policy is a separate possible contract, not an implemented blanket transcript permission. No historical purge, backfill, real-content classification or export is activated.

Errors include indistinguishable 404 not_found for unavailable private targets or authority, 400 invalid_request, 403 same_origin_required, 409 support_review_disabled, and 503 audit_unavailable or 503 unavailable. If review is unavailable, verify the exact target and current grant; do not broaden tenant scope or bypass the audit requirement. Use only explicitly authorized synthetic conversations to qualify a rollout.

Governance dependency health

GET /governance/health is an unauthenticated read-only operational probe. It returns HTTP 200 with the standard Governance build fields and memory: {reachable, ready, mode, error}. Check status: unresolved Memory storage or trust makes it degraded. Fetch and body reading share a two-second deadline and a 64 KiB byte limit.

Optional memory.identity contains only {commit,ref,environment} from the bound Memory /health response: upstream service must equal atlas-memory, commit must be exactly 40 lowercase hexadecimal characters, ref must be main or staging, and environment must be development, staging or production. Missing or malformed identity is omitted as a whole. Valid differing ref/environment values are preserved, never replaced with Governance's identity.

For internal Memory release acceptance, compare all three identity values with the captured intended Memory build and check memory.ready separately. Missing identity can coexist with readiness but cannot prove a release match. A valid not-ready HTTP 503 Memory response may retain identity; malformed, timed-out or other failed upstream responses cannot supply it. Memory remains private to its Service Binding; this adds no public Memory route.

Failure classes remain binding_missing, probe_timeout, probe_failed and memory_not_ready. Optional detail contains only known trust error classes, never arbitrary upstream diagnostics, caller records, key identifiers or secrets. /healthz remains the separate liveness probe.

Retained feedback and source privacy

POST /knowledge/sandbox/ask accepts optional scope_label and scope_descriptor strings. Send the same trimmed scope wording (at most 200 characters) in the ask and rating. A completed answer may include an opaque feedback_proof_id. Keep it with that exact answer and send it unchanged to POST /knowledge/feedback with the same question, answer, sources and scope. Never reuse it for another answer, scope, caller or organisation. The server checks access; the proof grants no access.

An invalid supplied proof returns 400 invalid_feedback_proof without creating feedback. Older responses without a proof and older rating bodies remain accepted. Feedback citing sources without proven access is retained for existing Knowledge reviewers but hidden from other readers. Deleting or reuploading a source under the same ID does not widen access to earlier feedback. Permitted readers keep the recorded answer and source name; a deleted source is marked available:false.

Merge customers

Current customer access is required. Reads and review use crm:read; commit, resume and cancel use crm:write, with CRM's current role and customer permissions still enforced. The verified credential supplies the organisation and actor; inputs cannot include org_id. This describes the local candidate capability, not production availability; keep it disabled until the complete journey passes acceptance.

  1. Select 2–10 customers. Start a read-only review with customer_ids and optional survivor_id.
  2. Edit the returned selection and review the complete selection again. Inspect each value's source and timestamp, conflicts, related records and owner consequences. latest does not replace required explicit decisions. Resolve ties, primary-contact and plan choices, and other blockers first. Values in choices and custom_field_sources are source customer IDs.
  3. Show the human the final name, retained original count, owner and access changes, then obtain explicit confirmation. Before sending, durably save one unique request_id, the complete selection, preview_digest and expires_at. Add confirmed:true when committing; a generic --yes cannot skip review.
  4. Inspect the same operation until state:complete confirms publication. pending is still processing; blocked needs the stated recovery. After response loss, look up the same request_id instead of creating a new request. Resume only the existing operation_id, without replacement choices.

Expired review or changed data requires another review, never a bypass of preview_digest. Cancellation can be requested only before apply starts; trust state:cancelled. Cancellation is not Undo. Continue recovery after apply has begun. After completion, inspect linked retained read-only originals through merge history. Current access still filters their contents; automatic Undo is unavailable.

Method and pathInput or result
POST /crm/customers/merge/previewcustomer_ids, survivor_id? | CustomerMergeSelection
POST /crm/customers/mergeCustomerMergePublicCommit
GET /crm/customers/merge/requests/:request_idCustomerMergePublicOperation
GET /crm/customers/merge/operations/:idCustomerMergePublicOperation
POST /crm/customers/merge/operations/:id/resume
POST /crm/customers/merge/operations/:id/cancel
GET /crm/customers/:id/merge-historyCustomerMergeHistory
GET /crm/customers/merge/operations/:id/originals/:source_idCustomerMergeOriginal

Complete selection example (replace with actual returned customers, contact and field sources; this is not a confirmed commit):

{
  "customer_ids": ["CUSTOMER_A", "CUSTOMER_B"],
  "survivor_id": "CUSTOMER_A",
  "mode": "latest",
  "choices": {},
  "owner_email": "[email protected]",
  "primary_contact_id": null,
  "custom_field_sources": {},
  "plan_customer_id": null
}

Every selection field is required. primary_contact_id and plan_customer_id may be null only when review permits. Commit adds request_id, the 64-character lowercase hexadecimal preview_digest, the unchanged expires_at in milliseconds and confirmed:true to the complete selection. Initial commit returns 202 and the full operation object. Resume/cancel do not accept a replacement plan. Operation results contain operation_id, request_id, state, phase, survivor_id, blockers, retryable and redacted receipts, never private snapshots. Originals return read_only:true and caller-filtered display records.

Internal Service Binding protocol (maintainers only) Internal Studio Task receipts Studio dispatch and shared command status Staff configuration drafts Reviewed same-draft proposals Internal staff-private Memory binding

Keep the same request_id through 409 merge_blocked, preview_stale, preview_expired and request-lookup 404. A concurrent submission can already have been admitted. After a fresh review and explicit confirmation, retry with that same key. On idempotency_conflict, look up the same key and recover the existing operation instead of replacing its plan. Retire the key only after confirmed state:complete or state:cancelled.

Internal customer merge protocol

These routes are for signed Worker Service Binding calls only, not public REST endpoints. Browser, MCP, CLI and manual HTTP callers use the public customer merge workflow instead. Senders sign with internalAuthHeadersWithBody; receivers verify the complete body with verifyInternalRequestWithBody before parsing or accessing data. Internal org_id comes from verified caller context, never public tenant input.

The exact fields, unions, validation and digest rules are defined in packages/atlas-core/src/customer-merge-protocol.ts; the table is an interface index. P means CustomerMergePlan; I means CustomerMergeIdentity. Participants are Tasks, Agent and EKB. CRM owns admission, publication, subjects and opportunities; Tasks owns task-bindings.

Method and internal pathRequest → response
POST /internal/customer-merge/previewCustomerMergeOwnerPreviewRequest → CustomerMergeOwnerPreview
POST /internal/customer-merge/prepare{protocol:1,phase:"prepare",plan:P,plan_digest} → CustomerMergeReceipt
POST /internal/customer-merge/apply{phase:"apply",identity:I} → CustomerMergeReceipt
POST /internal/customer-merge/status{phase:"status",identity:I} → CustomerMergeOwnerStatus
POST /internal/customer-merge/release{phase:"release",identity:I} → CustomerMergeReleaseResponse
POST /internal/customer-merge/admission{protocol:1,org_id,customer_ids} → {protocol:1,customers}
POST /internal/customer-merge/publicationI → {identity:I,state}
POST /internal/customer-merge/subjects{protocol:1,org_id,customer_ids,subjects:[{kind,id}]} → {protocol:1,request_digest,revision,bindings:[{kind,id,customer_id}]}
POST /internal/customer-merge/opportunities{protocol:1,org_id,customer_ids} → {protocol:1,request_digest,revision,opportunity_ids}
POST /internal/customer-merge/task-bindings{protocol:1,org_id,task_ids} → {protocol:1,request_digest,revision,bindings}

Preview checks without reserving an operation. Prepare seals the confirmed plan and each owner's revision. Apply/status/release accept only the existing identity, never a replacement plan. CustomerMergeReceipt binds the owner, full identity, phase, expected_revision, revision, counts and state, with receipt_digest and receipt_mac. Verify the actual Service Binding owner too; a response's self-declared owner is insufficient. Status returns durable receipts only. Release returns a released receipt after publication, or CustomerMergeCancellationAck when CRM has cancelled. There is no separate cancel phase endpoint. Cancellation acknowledgement uses phase:"cancel",state:"cancelled" without fabricating a prepare receipt.

Admission returns each customer's available, pending (with operation_id) or merged (also survivor_id) state. Publication state is preparing|prepared|applying|publish_ready|complete|cancelled. Only CRM's complete decision publishes; an owner's applied receipt does not. If publication cannot be verified, remain pending and withhold moved data.

Subject kind is customer|opportunity|comm_item. Unknown or foreign subjects make the entire read return 404; only a known unlinked comm_item may have customer_id:null. Each task binding is {task_id,customer_id,related_customer_ids,source_id,merge_state}; pending adds operation_id, merged adds operation_id and survivor_id, and survivor_id must equal current customer_id. Admission and task-binding ID lists are sorted, unique and contain 1–100 IDs; subjects permits 0–100 customer_ids and up to 100 unique subjects. Bindings match request order and bind request_digest and revision. Reject missing, reordered, digest-mismatched or identity-mismatched results instead of assuming available.

CRM's opportunities read uses CustomerMergeOpportunitiesRequest and CustomerMergeOpportunities. Its customer_ids must be sorted, unique and contain 2–10 customers. The reply binds request_digest and revision and returns sorted, unique opportunity_ids, at most 2,000. Exceeding the limit returns 409 snapshot_limit; do not truncate and continue.

Tasks requires related_customer_ids, a sorted, unique list of 0–2 IDs containing all current direct and opportunity-linked customer associations. Callers must check admission for every related customer instead of only the single customer_id. When the stored direct customer_id is null, the reply may use the opportunity's customer as effective customer_id; this does not rewrite the stored null or replace another independent customer association. With no association, effective customer_id is null and the list is empty. A failed association read or pending association must not be treated as safe to continue.

Check current member role before merge

Auth's existing internal POST /internal/members/eligibility also accepts signed Service Binding requests only. Without the new option, {org,email} still returns only {eligible}. Opt in with {org,email,include_role:true} to receive {eligible,role}, where role is the freshly resolved rep|manager|admin|null; ineligible membership returns role:null. CRM rereads current membership and role before merge instead of relying on an old session role, client claims or cached results. This check has no salesCrm setting gate and grants no additional customer or assignment authority.

Reserved task inventory

Signed POST /internal/customer-merge/task-inventory accepts the raw merge identity I. Tasks verifies that exact operation is prepared, applied or released and returns {protocol:1,identity:I,task_ids}. The sorted unique task IDs come only from its immutable reserved snapshot, with a maximum of 2,000. Unknown identities and unprepared or cancelled operations are refused. This is not a public task lookup. Agent prepare runs after Tasks prepare and installs its own reservation for these IDs, including tasks without an existing Agent draft, so a late draft cannot escape the merge. Consumers use parseCustomerMergeTaskInventory with the expected identity.

Approve selected business facts from published email

Apply CRM migrations 0163_selected_email_facts.sql, 0164_selected_email_analysis_admissions.sql, then 0165_selected_email_merge_fences.sql before the combined CRM code. Verify the actual ledger and no pending customer merge before schema changes. The added fences retain immutable approval receipts and frozen analysis admissions; they do not reset uncertain paid work or rewrite migration0157.

POST /crm/customers/:customerId/email-facts/analyze accepts only {comm_item_id,publication_id} and returns 200 {written,items,skipped}. This generates reviewable proposals from the selected published email without supplying facts or a body. Use the publication.id returned by the existing publication confirmation. The existing governed extraction workflow uses current organisation model policy, credit reservation and actor-attributed usage; only the published direct body, bounded to 8,000 characters, is analyzed. It uses a closed business-only prompt and revalidates literal citations, stated/new claims, current authority and the original source/customer state after extraction. Unsupported claims are skipped, and no supported information returns an empty list. Model/policy failure returns 503 extraction_unavailable with no fallback or fact write. The result stays pending until the separate selected preview and approval.

These backend endpoints act on one already-imported inbound email and one explicitly selected existing customer. A signed-in human needs current Auth membership, crm:read, crm:write and access to that customer. First use the existing POST /crm/communications/:id/publish sharing confirmation to publish the chosen evidence to that customer. A customer link alone gives no mailbox access. These endpoints never publish evidence, create permissions or scan mail; only the explicit analyze action calls the governed model. An already-published copy uses current CRM authority even after mailbox access is revoked; a removed publication is refused.

POST /crm/customers/:customerId/email-facts/prepare accepts {comm_item_id,publication_id,facts:[{key,value,category,citation}]} and returns 200 {written,items} with pending rows from that exact publication. Values must occur in literal direct-body citations. Allowed categories are intent, preference and other; keys are main_markets, products, brands, requirements, shipping_address, purchase_intent, communication_preference and business_background. Quoted, forwarded, fabricated and personal claims refuse. A key with a confirmed value is skipped without changing it. Existing governed scheduled proposals also use these pending facts, but only after live assignment authorization and publication resolution.

POST /crm/customers/:customerId/email-facts/preview accepts {comm_item_id,publication_id,fact_ids} with 1–20 unique selected IDs and returns {customer_id,comm_item_id,publication_id,review_digest,facts}. Each selected fact includes {id,key,value,category,citation,status}. Preview changes nothing. POST /crm/customers/:customerId/email-facts/approve accepts the same selection plus {review_digest,idempotency_key,approved:true}. It refreshes authority, checks source/customer/fact state and commits all selected additions with their immutable receipt in one transaction. It returns 200 {receipt,destination,replayed}; destination is current existing fact-row readback. Receipt fields are {id,customer_id,publication_id,comm_item_id,fact_ids,review_digest,approved_by,approved_at}. Retry with exactly the same selection, digest and key to get the same receipt. Generic fact confirm/edit and bulk-confirm cannot promote these rows.

Refusals: 400 invalid_selection, invalid_business_evidence or explicit_approval_required; 403 permission_refused; 404 not_found, published_source_not_found or fact_not_found; 409 stale_review, verified_value_exists or idempotency_conflict; 503 receipt_unavailable. Stale or concurrent edits refuse the complete approval. Missing publication authority fails closed. No verified value is overwritten or superseded; no automatic merge, history processing or new customer is supported. See packages/atlas-core/src/selected-email-facts.ts for types. Migration 0163_selected_email_facts.sql must precede the CRM deployment. Source documentation does not prove staging or production availability.

Generic confirm/edit and bulk-confirm also refuse the former scheduled producer’s AI facts carrying Email citation (comm_…) in their value. They cannot bypass selected approval when formal provenance is absent or the publication is removed. This guards writes without rewriting or deleting historical rows; ordinary non-email facts retain their existing actions.

Analysis uses durable admission for the exact actor, publication, customer and confirmed-business context. Identical requests reuse completed work and return current pending rows with written:0, replayed:true. Changed source/customer/context is a distinct admission. Concurrent duplicates wait briefly; a still-running or uncertain admission returns 409 analysis_in_progress. Retry the identical request later to read completion; no timed lease or failure automatically starts another paid run. Terminal failure remains 503 extraction_unavailable. Every paid retry/fallback rechecks source and authority before reservation and dispatch. Pending facts and admission completion commit atomically. Apply the three migrations in the order stated above before deploying CRM.

GET /crm/customers/:customerId/email-facts/analysis-status?comm_item_id=…&publication_id=… is the read-only status companion for that analysis. It recomputes the exact current context digest and returns 200 {admission,context_changed}: admission is null or {state,created_at,updated_at,fact_count,skipped_count} with state one of running, completed or failed (fact_count/skipped_count only for completed runs); context_changed is true when the current context has no admission but an earlier one exists for the same actor, customer and publication. The read performs no write, holds no lease, never admits or restarts a paid run and never dispatches the model: opening, refreshing or repeatedly checking status can never spend. It requires the same session authority as the family and refuses 400 invalid_selection, 403 permission_refused and 404 published_source_not_found. Use it to tell a still-running analysis from a durable failure, and a completed run whose staged facts await explicit approval from a completed run with no new facts.

Internal evaluation cleanup: POST /crm/eval/teardown is a signed, non-production Service Binding interface, blocked at the public gateway. It deletes disposable email-fact provenance with the other eval data but retains customer_email_fact_receipts and customer_email_analysis_admissions. Their table entries report removed:0 and their actual remaining counts; these counts contribute to the manifest total. A nonzero total does not prove a clean world. Cleanup never resets a paid admission or removes immutable receipts.

Internal Studio Task creation receipts

This Service Binding interface is unavailable to public REST, CLI and MCP clients. It does not activate Studio capabilities or create another task producer.

POST /tasks/internal/studio/review-receipt accepts the canonical StudioWaitOwnerBinding. Organization and actor are derived only from the verified, body-bound, 30-second principal assertion: audience tasks, surface rest, action agent.team.change, scope tasks:write. Agent resolves current Auth and Team source visibility before signing; Tasks also checks current task visibility. Cookies and an internal HMAC alone are insufficient. Platform and scheduled actors are refused.

The technical storage opt-in STUDIO_REVIEW_STORAGE_VERSION=atlas.studio.task-review-storage.v1 is unset by default. Without it, legacy Task creation is unchanged and the receipt endpoint returns 503. Explicit local development may bootstrap the additive receipt table. Deployed environments only probe migrated schema; this change applies no live migration or configuration. With storage admitted, the existing signed Team-work creation branch writes its creation witness in the same D1 transaction.

A successful read returns the canonical StudioItemResponse with the matching request_id and either a confirmed Tasks effect or item:null when original work is absent or inaccessible. The immutable witness checks the original proposal, hire, business key and payload digest even after Task edits. Invalid requests return 400, invalid assertion/body binding 401, conflicting receipt binding 409, and unadmitted or unavailable storage 503. Current customer/opportunity merge fences remain authoritative: customer_merge_pending or customer_merged returns 409. Null, failure or timeout leaves the original command uncertain: reconcile that same business key and never blindly create another Task.

Studio dispatch: capabilities and shared command status

The Agent Worker mounts the canonical Studio surface behind its existing verified-principal gate. GET /agent/studio/capabilities is read-only discovery over the B00 contract: an ordinary session (or an introspected delegated token with crm:read) receives the closed capability matrix; no connected operation is advertised, and upstream bearer failures keep their owning statuses (503 credential_store_unavailable, 403 organisation_suspended, 401 authentication_required).

GET /agent/studio/commands/:command_id reconciles one command id across the independently stored Agent owners: the staff configuration receipts and the graph preview commands. Ownership is established from the org/actor-scoped receipt tables, never guessed from the id: a command found by both owners is a genuine cross-store collision and answers 409 idempotency_conflict; a command found by exactly one owner returns that owner's canonical status; a command unknown to both returns an item-wrapped not_observed status, which proves nothing about execution. Every Studio response carries Cache-Control: no-store and Vary: Cookie, Authorization.

Staff configuration drafts (native owner routes)

The mounted routes follow the frozen B00 wire contract and the PR5392 native actions: GET /agent/studio/staff-configurations?limit=10, POST /agent/studio/staff-configurations, GET /agent/studio/staff-configurations/:definition_id/draft, PATCH /agent/studio/staff-configurations/:definition_id/draft and POST /agent/studio/staff-configurations/:definition_id/draft/validate. Create and patch take the strict staff envelope with expected_hire_revision and return item-wrapped StudioCommandStatus, never an assumed saved draft.

Authorization is the existing native Team path: an ordinary session, current Auth membership, live hire visibility and the hire.configure gate for writes; reads require current membership. The six reference fields must match the native catalog's current resolution for the exact hire revision — stale, foreign or fixture pins are refused. Delegated bearer writes are unavailable (B20 pending). Storage is the additive 0043_studio_staff_configurations.sql tables; without adopted storage the routes answer 503 dependency_unavailable. Errors use the canonical Studio codes (400 invalid_definition, 401 authentication_required, 403 permission_denied, 404 source_unavailable, 409 revision_conflict).

Reviewed same-draft proposals (public adapter)

GET /agent/studio/draft-proposals/:proposal_id, POST /agent/studio/draft-proposals/:proposal_id/apply, POST /agent/studio/draft-proposals/:proposal_id/discard and POST /agent/studio/draft-proposals/:proposal_id/reconcile serve the canonical B12 projections through the mounted dispatch. Decisions take only {review_digest}; reads take no query. Responses exclude private model/run provenance and journal history.

POST /agent/studio/draft-proposals (generate) and .../undo stay 409 decision_required: their trusted caller-retained creation-command context is a release decision, not a route body. Apply additionally requires the explicit STUDIO_PROPOSAL_APPLY opt-in; all operations require the durable journal opt-in STUDIO_PROPOSAL_JOURNAL=atlas.studio.proposal-journal.d1.v1 plus STUDIO_PROPOSAL_GRAPH_LIMITS for graph-form candidates — without them the routes answer 503 dependency_unavailable or 409 decision_required, never a synthetic fallback. The journal, creation intents and deletion fence live in the additive 0044_studio_proposal_journals.sql tables, scoped by the org/actor hash; an uncertain outcome answers 409 delivery_uncertain with retry: read_status.

Internal staff-private Memory binding

Memory serves POST /internal/studio/staff-memory and POST /internal/studio/staff-memory/content to the Agent over the existing Service Binding. The request is signed per-caller (kid/key rotation, body HMAC, 30-second window); org/actor travel only in the signed target query. Native staff policy is unavailable by default and no caller can enable it through a body field: without an approved policy reader the binding answers decision_required before any database use. Content reads keep the native reviewed-eligibility, purpose, sensitivity, validity and retention constraints; private episodes stay untrusted data. Storage is the additive 0009_studio_staff_memory.sql tables. There is no public Memory route.

Personal Assistant identities

Personal Assistant identities are presentation metadata owned by the agent pillar: one name and avatar per person, never a hire, grant or execution principal. Inspect the directory with agent.team.inspect / agent_team_inspect using {"view":"assistants"}; the answer carries setupSchema: atlas.assistant-setup.v1, the permanent head identity labelled Head Assistant / 总助理, and the caller’s own custom identities. Avatar keys are human-01 through human-08. Identities and their proposals are owner-only: another principal’s proposal answers 404 not_found.

Create or update with agent.team.change: {operation:"assistant.create",identity:{name,avatarKey}}, or {operation:"assistant.update",assistantId,revision,identity:{...}}. New named assistants automatically use Atlas’s existing task-time capability resolution: enabled organization skills, callable connected plugin tools, built-in functions and authorized knowledge retrieval; published organization guidance and the current user’s active memory resolve through their existing owners. A clear bilingual job name infers the setup focus; an unclear or conflicting name returns 400 assistant_purpose_required with status: rejected before a proposal — supply optional identity.purpose as one trimmed description of 1–400 characters without control characters. A saved custom identity may include setup: {mode:"automatic",focus,source:"name"|"clarified"}; legacy identities omit it. Avatar-only edits retain focus; a changed name infers again or asks for clarification; an explicit purpose overrides it. Focus steers task skill selection as untrusted user data; it grants no authority, makes no model call and publishes no memory.

Both operations propose through the governed ledger and stay awaiting_confirmation until confirmed; propose and confirm each require an Idempotency-Key. The v2 app performs the creation confirmation on the member’s single action, and keeps the explicit review step for updates. The permanent Head identity is immutable: assistant.update on head is refused. Storage is additive: migrations 0049_agent_assistant_identities.sql and 0051_agent_assistant_setups.sql create agent-owned tables; a missing table answers 503 team_unavailable until the migration is applied, and applying it creates no rows. CLI: atlas assistants inspect/propose/confirm — atlas team remains supported with the same API and permissions. Creating identity metadata makes no hire, model call or new permission.

Private conversations with a named Assistant

Source contract atlas.assistant-conversations.v1; availability requires the deployed Agent implementation, Agent migrations 0049_agent_assistant_identities.sql then 0050_assistant_conversations.sql, a live organization session, current owner identity and known-open ai-team eligibility. No account permission or feature flag is enabled by these routes. Assistant names/avatar keys are presentation metadata and confer no staff authority. The directory's conversationStatus enum is unchanged.

  1. Read GET /agent/assistant/conversation-capabilities?assistant_id=asst_…. Enable custom chat only on available:true, history:events and the expected schema version. Missing identity/eligibility gives available:false; outages give 503.
  2. Create with POST /agent/assistant/threads and {assistant_id,client_thread_id,title}. Title is optional, at most 200 characters; the creation key is 1–160 ASCII letters, digits, underscores or hyphens. Response is 200 {item:{id,assistant_id,title,archivedAt,messages:[]}}. Keep the same key and input on retry; changed input gives 409 idempotency_conflict. The server chooses the thread ID. Reserved head is never a literal thread ID.
  3. List with GET /agent/assistant/threads?assistant_id=asst_…; read a descriptor with GET /agent/assistant/threads/:id?assistant_id=asst_…. Custom descriptor messages stay empty: use events for the transcript. Default and Head collections preserve existing personal history and exclude custom threads. Custom PUT replacement gives 409 authoritative_history_required.
  4. Send POST /agent/chat with {assistant_id,thread_id,client_turn_id,messages:[{role:"user",content:[{type:"text",text:"Review my next steps"}]}]}. The send key is 1–64 ASCII letters, digits, underscores or hyphens. SSE uses existing frames and run_id. Earlier client history is discarded; the server log supplies authoritative prior turns. Existing gateway policy, metering, admission, source permissions and tool confirmations apply.
  5. Reconnect through GET /agent/assistant/threads/:id/events?assistant_id=asst_…&after=0. Continue with returned head and optional wait=1. Duplicate sends give 409 duplicate_send with run_id and send_status, never a second execution. Live competitors give 409 conversation_busy. Read GET /agent/assistant/threads/:id/sends/:client_turn_id?assistant_id=asst_…: status is reserved, running, closed, cancelled or interrupted_or_expired. Missing retained outcome is unknown, not proof of failed external effects.
  6. Stop with POST /agent/assistant/threads/:id/runs/:run_id/stop?assistant_id=asst_…; delete with DELETE /agent/assistant/threads/:id?assistant_id=asst_…. Idle interrupted turns can be fenced after 45 seconds; late openings cannot run unlogged. Resume reads state and does not promise exactly-once external actions. Check each action's own receipt before initiating another operation. Existing event retention limits apply; deleted logs remain tombstoned and interrupted cleanup can be retried.

Every custom detail/events/decisions/status/stop/delete request needs the matching assistant_id. Another user/org, missing or mismatched identity, removed identity and disabled binding receive 404 not_found; unauthorized collections receive 200 {items:[]}. Custom threads have no legacy support-review audience: review, diagnostics and support-grant routes refuse them. Missing durable storage gives 503 conversation_unavailable. This page is an API reference, not proof of deployment or live acceptance.

Automatic Assistant capabilities

New named assistants automatically use Atlas’s existing task-time capability resolution: enabled organization skills, callable connected plugin tools, built-in functions and authorized knowledge retrieval. Published organization guidance and the current user’s active memory resolve through their existing owners. No manual assignment, installation, new permission or memory publication is performed.

The directory advertises setupSchema: atlas.assistant-setup.v1. A clear bilingual job name infers focus; an unclear or conflicting name returns 400 assistant_purpose_required with status: rejected and error.code: assistant_purpose_required before a proposal. Supply optional identity.purpose as one trimmed description of 1–400 characters without control characters. Review the effective focus, then explicitly confirm. A saved custom identity may include setup: {mode: automatic, focus, source: name|clarified}; legacy identities omit it. Avatar-only edits retain focus; a changed name infers again or asks for clarification, and an explicit purpose overrides it. Focus steers task skill selection as untrusted user data; it grants no authority.

Shared governance traces retain run identity, usage and policy metadata without private conversation content, plans or tool inputs and outputs. Owner conversation history remains available.

Private assistant history compatibility

Thread IDs beginning asstchat_ are reserved for private named-assistant conversations. Legacy thread, chat, review, diagnostics, support, search and background paths cannot read or change these conversations. Legacy lists, pruning and review discovery exclude them before pagination. Existing Head history remains available. Named-assistant endpoints require their separate versioned capability and current owner access; an older server must not fall back to ordinary chat.

Assistant Properties

GET /agent/assistant/properties/:assistantId?locale=en returns atlas.assistant-properties.v1 for a named asst_… identity owned by the current actor and organization. Use locale=zh-CN for Chinese instructions and skill labels. Only a live browser session whose verified email has the exact domain forth.ai, forthai.work or forthai.cn may read it. Verified ordinary and platform human sessions are supported, including safe role preview. Platform authority never grants access to another actor’s assistant. Bearer credentials, delegated tokens and agent callers are denied.

The read-only model, instructions, skills, tools and knowledge sections contain a state, source, selection mode, configured text and bounded name/ID lists. configured, unconfigured and unavailable distinguish known configuration from missing or unreadable values. Task-time selection is labeled; active knowledge metadata is limited to 100 authorized sources. Personal library and private connector sources remain task-time reads. Provider credentials, private conversation content, personal memory and retrieved passages are excluded. Inspection makes no model call, tool execution, configuration write or connection.

Responses use Cache-Control: private, no-store. Foreign or missing assistant IDs return 404; an ineligible domain returns 403; changed authority returns authority_changed; a changed assistant returns assistant_changed. The Agent compares current Auth role, scopes and effective permissions with the session before metadata reads, and rechecks Auth after them; stale claims return 403 authority_changed before source lookup. An unpreviewed Auth /me read also verifies current Owner authority, actor, organization, effective permissions and expiry before and after metadata. Platform base/member roles remain distinct from effective admin authority, and current platform authority is also verified; omitted owner means false. Existing role-preview limits are reapplied, and proof reads never adopt rotated cookies. Malformed or partially malformed skill, tool or source catalogs make their sections unavailable; only a valid empty catalog is treated as configured with no entries. Head is deliberately absent from this endpoint: desktop main reads its actual local runtime configuration, without starting its engine or login. Head has no pinned model and no implicit Agent skills, tools or knowledge access.

Recoverable deal and quotation writes

STORY-V2-ERP-03 adds two OPTIONAL fields to existing CRM write routes; both are absent-and-ignored, so existing callers behave byte-identically. expected_updated_at (integer epoch milliseconds) is accepted by POST /crm/engagements/:id/stage and PATCH /crm/engagements/:id: it is the row version the client displayed when it decided. When the deal has changed since (customer_engagements.updated_at differs), the write is refused 409 {"error":"snapshot_expired","current_updated_at":<ms>} BEFORE any history row, timeline row or column update is written — the caller keeps its intended change and must re-read the deal (the body names the current version) and reapply after a refreshed review. A malformed value (non-integer, negative) answers 400 invalid_expected_updated_at. The guard composes with every existing rule: lost-reason validation, stage_unchanged, and the Atlas guarded-update path are unchanged.

request_key (8–80 characters, starts alphanumeric, then A-Za-z0-9_-) is accepted by POST /crm/customers/:id/engagements and POST /crm/engagements/:id/quotations and makes the create replay-safe: a client whose write timed out or whose app restarted re-submits the SAME body with the SAME key and reads back the SAVED record — 200 with the engagement/quotation plus "deduplicated": true — instead of creating a second deal or a second quotation draft. Keys are organisation-scoped (two organisations may use one key independently); a malformed key answers 400 invalid_request_key before anything runs; a key currently being submitted concurrently answers 409 request_key_in_flight (retry the same key); a key whose created record was later deleted answers 409 request_key_spent and requires a fresh key — never a silent re-create. The receipts live in the additive migration 0170_deal_command_receipts.sql (table deal_command_receipts, UNIQUE (org_id, request_key)); no amounts, participants or relationship counts are exposed by any of these refusals.