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

280 lines
22 KiB
Markdown

<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# 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
```bash
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](../../../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](../modules/invoicing/README.md).
## 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:
```sh
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](../../../docs/reference/app-kit.md#claimed-business-jobs-on-postgres).
### 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.