Files
template-app/docs/business-services.md
T
2026-11-14 09:56:45 +00:00

22 KiB

Business service modules

ConusAI Cloud runs business services next to the platform: Inventory, CRM, Catalog, Activities, Invoicing, Ledger and Archive. Each keeps its data in your organization on the platform and is called through the platform API with a generated client, @conusai-sdk/<service>. This template ships optional modules that use them -- modules/inventory (items, stock, reservations), modules/crm (parties and how to reach them), modules/catalog (products and their imports) and modules/archive (documents, versions and retention) -- plus modules/activities (project tasks, discussion and timeline). All ship off.

Source status: request-bound adapters and simulated contract checks exist. The gateway-backed request composition, final release and live acceptance are still being integrated. Including a module does not establish readiness or authorize processing.

Turn a module on

pnpm kit module:on inventory      # or crm, catalog, archive, activities; `pnpm kit module:off <id>` turns it off again
pnpm openapi                      # the module's operations join openapi.json
pnpm format                       # reflow edited configuration before the lint gate
pnpm gate

module:on does two things and prints what it changed:

  1. adds the id to selected and everAdopted in modules/inclusion.json: the module joins the registry, its procedures the API, its page the project's navigation;
  2. asks the deployment token for the service's scopes in conusai.json (token.permissions, and every supported profile's copy (the Convex preview is refused)):
Module Scopes
inventory inventory.items:read, inventory.items:write, inventory.stock:read, inventory.reservations:read, inventory.reservations:write
crm crm.parties:read, crm.parties:write, crm.contacts:write
catalog catalog.products:read, catalog.imports:read, catalog.imports:write
activities activities.activities:read, activities.activities:write, activities.comments:write
archive archive.documents:read, archive.documents:write; hold, retention and disposal administration need separate reviewed grants

The deployment requests the scopes; an organization owner/admin must approve the permission delta separately. The service must also be enabled for the deployment's organization on the platform (Settings, Modules, or conusai modules enable <service> --org <slug>); until it is, the module's page says so instead of failing.

module:off removes selection while retaining historical custody and existing scope requests for cleanup. It does not revoke an approved credential. The request ownership record remains .conusai/module-scopes.json; customer-owned and historical untracked requests remain untouched. Physical source stays and is type-checked either way. Removing never-adopted source requires review; an adopted module must retain its original schema and rights/retention implementation.

What the modules do

Module Page Procedures (kind, app permission)
inventory /<org>/<workspace>/<project>/inventory: the organization's items, a form that adds one, this project's open reservations with a release button; /inventory/<itemId>: the item's stock by location and a form that reserves some for this project inventory.item.list (read, inventory.item.read), inventory.item.create (effect, inventory.item.write), inventory.item.availability (read, inventory.item.read), inventory.reservation.list (read, inventory.item.read), inventory.stock.reserve and inventory.stock.release (effect, inventory.stock.reserve)
crm /<org>/<workspace>/<project>/crm: the organization's parties and a form that adds one; /crm/<partyId>: the party's contact points and a form that adds one crm.party.list, crm.party.get (read, crm.party.read), crm.party.create, crm.contact.add (effect, crm.party.write)

| catalog | /<org>/<workspace>/<project>/catalog: the organization's products and a search; /catalog/<productId>: one product; /catalog/imports: the imports so far and a form that applies a CSV file | catalog.product.list, catalog.product.search, catalog.product.get, catalog.import.list (read, catalog.product.read), catalog.import.apply (effect, catalog.product.write) | | archive | /<org>/<workspace>/<project>/archive: the organization's documents and a form that registers one; /archive/<documentId>: the document's versions, a form that adds one, its legal hold and disposal; /archive/retention: the retention classes, a form that adds one, and what is due for disposal | archive.document.list, archive.document.get, archive.retention.list, archive.disposal.list (read, archive.document.read), archive.document.register, archive.version.add (effect, archive.document.write), archive.document.hold, archive.document.dispose, archive.retention.create (effect, archive.retention.manage) |

inventory.item.read, crm.party.read, catalog.product.read and archive.document.read are held by every member (viewer); archive.retention.manage by administrators, because a hold, a disposal and a retention policy change what the organization keeps; the others by editors. A change is an effect procedure: it calls a service and writes nothing of this app's own, so none of these modules has a table, a migration, a job or an event.

Reservations belong to a project. The service holds a reservation against a reference, and the module uses the project's: conusai://app-partitions/{binding UUID}/projects/{resolved app project UUID}. A project lists only its own reservations, and releasing another project's answers not-found. A form sends a request id the module turns into the service's idempotency key, so a retried request reserves (or creates) once.

An import is one file. The form reads a CSV file with the columns code, name and base_unit (description and revision are optional), hashes it, and sends it in chunks of 200 rows, each with the import's revision from the chunk before. A row the service refuses is listed with its reason and the others stay applied; sending the same file again applies nothing twice. To replace a product that exists, give its revision, or the row is refused as a conflict.

A document is made from a file the project holds. archive.document.register and archive.version.add take a fileId of the files module. The hash, size and type are the ones the store measured, the service keeps a Blob reference that names the file and nothing else of it, and a file of another project or organization is simply not there. A disposed document changes no more. The files module does not know a file is archived: deleting the file there removes the bytes a version points at, so an app that archives should not let members delete those files.

A person can be linked to a member. "This is me" on the party form stores the member's subject reference (actorRef(bindingId, memberId) of @conusai-kit/business) as an identifier of scheme conusai.subject. It must match the gateway's attributed app actor. The partition-aware privacy worker must resolve that exact member and binding; an unsupported callback leaves the obligation pending and cannot certify erasure.

Your own codes and fields on a party

A party can carry what the service has no field for, and your own code for it.

  • The credential is scoped to an immutable app/environment binding. The server resolves current app membership and permission before preparing the request. The gateway validates the deployment credential, binding and attributed actor. Rotating a credential does not remap retained data.
  • Use the binding's namespace. An extension uses app_ followed by the binding UUID's 32 lower-case hexadecimal digits. The service validates that namespace against the signed partition. Request crm.extensions:write separately and obtain an explicit grant before defining a field; module inclusion does not grant this administrative scope. Existing app fields need a reviewed adoption plan; do not rename or copy customer records automatically.
  • Your code is an identifier. A scheme such as app.<binding namespace>.party_code belongs to that partition. Its code is unique among that partition's active parties. Use exact source values; another app tenant or environment has independent uniqueness and cannot adopt this namespace.
  • A person you store is a reference. Use actorRef(bindingId, resolvedMemberId) for an attributed app member, subject to the service's typed field and subject rules. Raw names, addresses and user IDs in arbitrary extension text need explicit subject handling; they cannot be inferred safely by an erasure worker.

Importing a file takes crm.parties:import, reading its ledger crm.imports:read, and roles crm.roles:write.

Where the data is, and who sees it

The serving path requires the current server-prepared RequestBusinessBinding. It includes the immutable binding, app tenant, environment and verified member attribution. A missing or unverified binding is refused before network dispatch; no organization-wide environment fallback or deployment singleton is created. The gateway and service must enforce the same tuple under nonowner database roles before an app flow can be accepted. Explicit test ports and synthetic readiness observations are isolated test fixtures, not proof of production tenant isolation. Existing service data is retained; legacy organization-only rows are not silently reassigned to an app partition.

CRM parties of kind person and their contact points are personal data, kept and erased by the platform's CRM service; the app stores none of it. An archived document's title and its versions are the Archive service's. The module registers a document with no subject, so the platform's export or erasure of a person does not reach it: do not put a person's name in a title unless your app also records them as the document's subject.

How a call is made

Each business module declares transport: "business" explicitly. Native Blob, OCR, AI and Knowledge requirements declare transport: "platform" and use their own observer and scope grammar. Existing apps must review and add this discriminator to their own requirements before adopting the matching kit release; package updates do not rewrite their module code or grant permissions.

Each module has a capability-limited port and a generated SDK adapter. Without an injected test port, the adapter resolves the prepared request binding from the authenticated dispatch's child container. It pins the credential, binding and actor, refuses another origin and redirects, and preserves the SDK's exact int64 codec. A read procedure receives only read operations. A manifest states supported API versions, profiles and exact per-operation scopes; an unavailable observation, provider or dependency is a refusal rather than implicit readiness.

The app composition creates these hooks lazily: modules that remain off need no extra environment metadata, and a missing business-service configuration refuses only the affected dispatch. @conusai-kit/business is a runtime dependency, including after production dependency pruning.

A refusal of the service becomes a typed error through the kit, with no mapping in the module: the adapter throws businessAppError(toBusinessError(error, response)) of @conusai-kit/business. An admission refusal is kit-core's service_<reason> (below), which the page shows in place of itself with serviceNotice(error, businessServiceNotices(i18n.locale)). Every other refusal is business_<refusal> with the service's status: business_not_found (404), business_invalid (400), business_conflict and the service's own reasons (business_insufficient_stock, business_unbalanced, business_legal_hold, ...; 409), and business_unavailable (503) when the service did not answer. A form shows the sentence for any of them with serviceNotice(error, labels.refusals)?.text, where the loader put businessRefusalNotices(i18n.locale) (one sentence per refusal code in LT and EN, a module's own wording passed as overrides) in the labels. The admission codes:

Code Status When
service_not_installed, service_not_enabled 404 Install the service or intentionally enable it for the organization.
service_permission_missing, service_not_entitled, service_partition_revoked 403 Review the binding's approved scopes, entitlement or revocation with an administrator.
service_partition_missing, service_incompatible, service_profile_unsupported 409 Configure the exact app/environment binding or review the supported release/profile.
service_configuration_missing 503 Complete the required provider/service configuration.
service_dependency_unready, service_migrating, service_degraded 503 Inspect the dependency, migration or health diagnostics.
service_observation_missing, service_observation_stale, service_generation_stale, service_signature_rejected 503 Refresh verified status or ask the operator to reconcile the release.

Remote error details are discarded, and a problem type a service does not answer with is about:blank; the UI uses the kit's bounded translated sentences. Amounts, quantities and dates are exact values of @conusai-kit/money and are shown through i18n.money, i18n.quantity and i18n.day (docs/how-to/use-money-and-refusals-in-an-app.md).

Testing

fakeBusinessServices({ inventory, crm }) of @conusai-kit/testing simulates the gateway and services as a fetch: bind a real client to it (businessClient(env).setConfig({ fetch: fake.fetch })) and the port to the client (sdkInventoryService, sdkCrmService). modules/<id>/__tests__/app.ts does exactly that; the modules' tests drive every procedure, the not-enabled and missing-scope states, a viewer's refused write, and the app membership isolation suite. Injected ports do not establish external partition enforcement. Activities additionally tests generated wire encoding, prefixed resource IDs, opaque cursors, exact revision digits, comment receipts and preservation of untouched fields using synthetic HTTP. Real gateway/service/runtime-role/browser/API/MCP acceptance remains mandatory and separate.

pnpm dev runs against the fake platform, which serves the business gateway and the services the way the real gateway does: binding lookup, the partition admission of each operation, and forwarding to a fake of CRM, Catalog, Inventory, Archive or Activities in the binding's own partition. The seed binds the demo organizations with the scopes the selected modules declare; an organization created later shows service_partition_missing until it is bound, as in Cloud. test/business-fake-platform.test.ts runs a procedure through that path. Invoicing and Ledger have no fake: their pages show service_not_installed there. The platform's guide is docs/how-to/test-business-services-locally.md in the conusai-cloud repository.

Invoicing adoption

kit module:on invoicing adds the optional organization-scoped invoice client. It requests only invoicing.invoices:read, invoicing.invoices:write and invoicing.series:read. An administrator must intentionally approve invoicing.invoices:issue, invoicing.invoices:void or invoicing.series:write before the corresponding fiscal/configuration controls can succeed. The app's fiscal actions also require its administrator role. Credit uses invoice-write scope but remains an app administrator operation. No privacy/delete scope is requested by inclusion.

Configure active numbering series and actual seller/buyer terms before drafting. CRM references are non-authorizing links; no CRM service access is inferred. Invoice data belongs to the app organization, including when its page is opened from a project. Existing applications adopt the new module and updated package pins explicitly, reviewing their own code; an update does not replace their modules, grant new scopes, enable the service or send invoices automatically. Issued data and numbering survive module disablement and require service-owned correction, retention and recovery procedures. See the module.

Optional inclusion and retained data

The shipped starter selects Agent (including the assistant), Guides and Support and records all three in everAdopted. Documents, Knowledge, Activities, Archive, Catalog, CRM, Inventory, Invoicing and Ledger start unselected and never adopted. Physical source stays available. A separately authored new app may begin with empty selection and adoption lists; that valid source contract is not the shipped default or a reset mode for an existing app. From the app root, explicitly select or stop a module:

pnpm kit module:on documents
pnpm kit module:off documents

The commands update modules/inclusion.json and deployment scope requests atomically. Review the diff, regenerate OpenAPI with pnpm openapi, run the app gates and deploy the reviewed revision. Selection requests only that module's operation scopes. It does not approve permissions, enable a Cloud organization/service, configure a provider or authorize costs. Configure those separately. The shipped starter already declares the native Guides bundle. When enabling Guides from an off configuration, publication needs a separate explicit deployment change (guides.bundle and the publishing service setting); selecting Guides alone does not restore publication. Support channel clients are lazy. Agent still needs independent model/tool policy and approval.

selected controls interactive/processing admission; everAdopted records custody under the original module identity and never shrinks automatically. Off keeps original schema/data and account-rights hooks, Knowledge removal and Agent/assistant retention. It keeps existing scope requests, including broad grants that cleanup currently needs, and adds no scope. It does not revoke an already approved credential. Do not delete data, migrations, the custody record or cleanup credentials merely because selection is off. Scope narrowing/removal requires separately verified obligation settlement and supported privacy APIs.

Old apps without this versioned record refuse automated toggling. Review their existing module composition, scope ownership, data and migrations, then explicitly adopt both the new composition and accurate selected/ever-adopted lists on a review branch. Do not seed an empty custody list for an app that previously used a module. Ordinary package/template upgrade does not perform this sensitive adoption. Convex remains preview; its separate composition needs coordinated adoption and unsupported module selection remains refused.

module:off guides also removes the top-level bundle declaration and per-service Guides publish roles from every deployment configuration, preserving unrelated fields and formatting. Re-enable requests runtime scopes again but does not restore publishing: opt in to publication separately.

Human-triggered business jobs

Declare the job's exact service scopes on its owning module's serviceRequirements, and configure createPostgresWorker with businessJobIntentPermissions: { "example.dispatch": ["example.dispatch.send"] }. Permissions come from trusted worker composition, not payloads or business service scopes. The Postgres worker owns the business job's retained claim, prepares its binding and admits current grants before invoking the handler. These handlers do not call ctx.execution.run again. The binding remains available through requestBusinessBinding.

Business clients recheck the original member's current intent, partition, grants, claim and real transaction fence before every request, and combine request cancellation with claim loss. For a separate mail/provider adapter, immediately before sending call assertClaimedJobBusinessAdmission(ctx.container) from @conusai-kit/business/server and use AbortSignal.any([ctx.execution.signal, timeoutSignal]). Keep stable idempotency keys and retries; already-started effects cannot be undone. Raw fetch calls are not automatically guarded.

Only project-scoped human-triggered Postgres business jobs are supported. Missing original users, revoked membership/permissions/grants/bindings, foreign scope, unsupported profile and missing or stale claim/fence refuse. Native jobs without business declarations keep their existing admission and execution contract. See the canonical API and proof limits.

Source-unit variant verification

From the platform checkout, run node scripts/vendor/test-template-module-variants.mjs templates/app /absolute/sdk/node_modules /new/evidence/directory with a compatible installed SDK/kit dependency tree. The helper constructs a separate new never-adopted source fixture with base-only permission requests before writing any adoption record; it does not erase an existing app's history. It executes the original admission, help/client and provider-construction assertions, including invalid-record and accidental-provider negative controls. Both fixture deployment configurations must pass the original generated platform-schema assertion. Each child suite has a two-minute deadline; timeout is a failed run, with no automatic retry. A second adopted fixture uses the public kit module:off guides command and verifies that Guides publication disappears across deployment profiles while adoption history remains unchanged. These are source-unit configuration checks with synthetic provider ports, not browser, database or deployed-platform acceptance. The shipped starter has no command to reset adoption history.