# ProjexCloud — agent guide

<!-- GENERATED by scripts/qa-matrix/build_sdk_catalog.py — do not edit by hand. -->

77 SDKs, 639 tenant-callable APIs. Gateway: `https://cloud.projexlight.com`

## Read this before writing any code

**Do not rebuild what an SDK already covers.** Match your capability against `sdk-catalog-index.json` (`reuse_when` keywords) first, then fetch that endpoint's full spec from `sdk-catalog.json`. `openapi.json` is there if you want a generated client.

## Authentication

Four principals, each with its own credential: PLATFORM OPERATOR (ADMIN_OPS_TOKEN, /admin/* only, rejects tenant JWTs); TENANT ADMIN (human, password -> JWT); TENANT APPLICATION (machine, pk_live_/pk_test_ API key); APP END USER (human, password -> JWT scoped to an app_id). The gateway is DEFAULT-DENY: every path needs a valid tenant JWT unless explicitly public.

### Rules that will cost you if you get them wrong

- The API key and the JWT use the SAME header: 'Authorization: Bearer <value>'. The gateway distinguishes them by the pk_live_/pk_test_ prefix. There is NO X-API-Key header.
- POST /api/auth/signup-tenant provisions an ENTIRE TENANT (org + app + tenant + membership). NEVER call it to register an end user of an app — use /api/auth/register.
- Credential-management routes (/api/applications, /api/api-keys) require a HUMAN JWT and reject an API key by design: a key that can mint another key cannot be contained.
- Do NOT build your own users/roles/sessions tables. identity + persona + tenant + rebac + policy already own that; a parallel table breaks tenant isolation and the audit chain.
- A human JWT carries NO scopes. Authority is resolved from the persona and its grants at request time, so a revoked role takes effect immediately rather than at token expiry.
- A MACHINE credential is different: its token DOES carry scopes and the gate enforces them. Scopes are <domain>.<resource>.<action> — THREE DOT-SEPARATED PARTS, derived from the request path (action = read for GET/HEAD else write, both segments singularised): GET /api/crm/contacts needs crm.contact.read; POST /api/sla/clocks needs sla.clock.write; GET /api/sequences needs sequence.sequence.read (a domain with no sub-segment doubles as its own resource). Tail wildcards work and are usually what you want: crm.* covers every CRM resource and action. THE VALIDATOR ACCEPTS ANY NON-EMPTY STRING ARRAY, so ['crm'] or ['crm:read'] is stored happily and then satisfies NOTHING — every call 403s. If a key 403s everywhere, read the body first: it names required_scopes and granted_scopes.
- A machine credential is ALSO bound to a synthetic persona (created with the key, in the same transaction) so machine traffic is attributable as actor {kind: service} instead of borrowing a human's. It starts with NO role grants. Routes that authorize on tenant + scope — most reads — work immediately. A route that consults ReBAC or a role template 403s WITHOUT naming a scope; that is the case that needs POST /api/role-assignments {persona_id, role_template_id}.
- POST /api/auth/token: client_id is OPTIONAL and the client_secret alone identifies the key, so omitting it is safest. If sent it must be the APPLICATION's slug or application_id — NOT the tenant's app_id, which is a different row. A mismatch is a hard 401 invalid_client, not a warning.
- Everything downstream keys on persona_id (L4), not on person_id and not on a user_id.

### A tenant signs up (bootstrap — no credential needed)

```
POST /api/auth/signup-tenant  -> person + alias + credential + org + app + tenant + tenant_membership + app_identity + profile band (one transaction)
POST /api/auth/send-verification-email  -> purpose-scoped short-lived token
POST /api/auth/verify-email
POST /api/auth/login  (optional tenant_id selects the membership)  -> six-layer JWT
```

### A tenant registers an application and a machine key

```
POST /api/applications                        (human JWT) -> application_id
POST /api/applications/{application_id}/keys  (human JWT) -> pk_live_/pk_test_
POST /api/auth/token                          (public, client_credentials) -> short-lived token
rotate: POST /api/api-keys/{key_id}/rotate | revoke: POST /api/api-keys/{key_id}/revoke
POST /api/role-assignments {persona_id, role_template_id}   <- REQUIRED. The key's synthetic persona has no grants until you add them, so every call 403s no matter how valid the key is. persona_id is the key's synthetic_persona_id from the issue call; role_template_id names a tenant.role_template, whose app_id is an app_id and NOT the application_id above.
environment (live|test) is a property of the APPLICATION, not the key, so a test app can never mint a credential that reaches production data.
app_id IS NOT application_id. app_id is TEXT (e.g. 'leadflow-dev-af4bd2'), the PK of tenant.app; tenant.tenant.app_id and tenant.role_template.app_id both REFERENCE it, and persona.app_identity.app_id carries the same value. application_id is a UUID, the PK of api_keys.application - the API-KEY CLIENT REGISTRATION, whose slug is the client_id for client_credentials. Passing an application_id where an app_id belongs is refused by role_template_app_id_fkey, whose message names neither concept. app_id answers 'which of your products is this?'; application_id answers 'which of your machines is calling?'.
```

### An end user signs up inside a tenant's app

```
POST /api/auth/register                       -> identity.person (+ alias, credential)
POST /api/app-identities                      -> app_identity, UNIQUE (person_id, app_id)
POST /api/app-identities/{id}/memberships     -> tenant + bu_id + starting role_template_id
POST /api/personas                            -> persona_id  (the acting identity)
POST /api/auth/login                          -> end-user JWT
One human in two of your apps is ONE person_id with TWO app_identities and TWO personas — not two accounts. Delete at persona level so other apps are unaffected.
```

### An existing user joins ANOTHER tenant's app

```
SUPPORTED, and it needs no new person. identity.alias has UNIQUE (kind, value_hash), so one email is one identity.person GLOBALLY — a human who is already a tenant admin somewhere is the same person_id when they join a different tenant's app.
POST /api/memberships                        -> tenant_membership for the OTHER tenant
POST /api/app-identities                     -> app_identity for that tenant's app_id
POST /api/memberships/{membership_id}/personas -> a SECOND persona, independent roles
Their existing personas are untouched. Login with tenant_id selects which membership the JWT is minted against; omit it to get a person-level token and let them choose.
DO NOT call /api/auth/register again for this person — it will fail on the unique alias, and that failure is the constraint doing its job.
```

### An existing app user becomes a provider (own tenant + apps)

```
RESOLVED (EP-328). POST /api/auth/signup-tenant now REUSES the existing person when the caller sends a verified token for THAT SAME person; anonymous callers still get PersonExistsError. A second identity.person for one human would split their audit trail across two ids that nothing can reconcile, so reuse is the only safe repair.
Compose it instead, from an authorised caller:
POST /api/tenants                            -> the new tenant (+ its tenant.app row)
POST /api/memberships                        -> bind the EXISTING person_id to it
POST /api/memberships/{membership_id}/personas -> owner persona + owner role_template
POST /api/applications                       -> their application, then keys
Correct fix if you need self-service: make signup-tenant reuse the existing person_id when the alias matches AND the caller proves control of it (verified session or re-auth), rather than refusing. Creating a second person for the same human would break MDM convergence and split their audit trail.
```

### One login across many providers' apps

```
SUPPORTED. A person has ONE identity.credential, so one password works across every provider's app they belong to. Which app they enter is chosen at LOGIN, not signup.
POST /api/auth/login {email,password}                  -> person-level token (no tenant claims)
GET  /api/memberships                                  -> every tenant+app they are subscribed to
POST /api/auth/login {email,password,tenant_id,app_id} -> scoped token; app_identity auto-mints
Subscription is PROVIDER-controlled: login with a tenant_id returns 403 NoMembership unless a membership exists, so nobody self-joins by guessing an id. The provider admits them via POST /api/memberships.
CAVEAT: the credential is global, so a provider collecting the password on their own page holds one that works at other providers too. For mutually-untrusting providers, host login centrally and redirect. There is no OIDC authorize endpoint yet.
```

### Getting an API key for an app (every step)

```
An API key belongs to an APPLICATION, which belongs to a TENANT. Order matters.
POST /api/auth/login {email,password,tenant_id}        -> human JWT (steps below need it)
POST /api/applications {name,slug,environment}         -> application_id
POST /api/applications/{application_id}/keys           -> pk_live_/pk_test_ (SHOWN ONCE)
call:  Authorization: Bearer pk_live_...   (same header as a JWT)
POST /api/auth/token (client_credentials)              -> short-lived token, preferred when the credential would otherwise sit on a device or in a browser
rotate POST /api/api-keys/{key_id}/rotate | revoke POST /api/api-keys/{key_id}/revoke | kill all POST /api/applications/{application_id}/disable
environment (live|test) is fixed on the APPLICATION and cannot be flipped; the prefix derives from it, so a test app can never mint a credential that reaches production.
The secret is returned ONCE — only a hash is stored, so there is no 'show it again' call.
Mint ONE KEY PER CONSUMER (web backend, mobile BFF, each CI pipeline) so revocation is about one consumer rather than an outage for all of them.
```

### Roles, relationships and policy

```
RBAC  tenant.role_template keyed (tenant_id, app_id, name); tenant_id NULL = a platform default for the app, tenant_id set = that tenant's override of the same role name. parent_role_template_id gives inheritance.
POST /api/role-assignments {persona_id, role_template_id}  -> grant beyond the starting template
GET  /api/personas/{persona_id}/roles         -> LIST what a persona holds (read-only; there is no POST on this path)
POST /api/role-assignments/{assignment_id}/revoke          -> withdraw a grant
ReBAC (sdk-rebac)  'may THIS persona act on THAT record' — owner/delegate/account team relationships, with trust state and evidence. Use when authority comes from a relationship rather than from a role.
ABAC (sdk-policy)  'do the attributes permit it right now' — region, consent, time, record state. Use when the decision does not depend on identity.
They compose RBAC -> ReBAC -> ABAC.
```

### One tenant, many apps

tenant.tenant.app_id is NOT NULL, so a tenant row belongs to exactly ONE app, while tenant.app_pool_index (jsonb app->pool) assumes several. To model one tenant owning several apps today, use the tenant hierarchy (parent_tenant_id / root_tenant_id): a root tenant per customer and a child tenant per app. Do not assume one tenant row can span apps.

## Audit events — emitting your own

Every audited action carries an event_type, and the vocabulary is CLOSED: a type in neither the platform baseline nor your tenant's own registered types is rejected before any write (OC-2). That constraint is deliberate — it is what stops lead.routed, lead.route and routing.applied all existing within one release, after which nothing can answer 'how often was a lead routed'. Your vertical's business events are NOT platform events: register your own rather than borrowing a vault.*/tenant.*/audit.* name, which would file your event under a name that already means something else.

### Rules that will cost you if you get them wrong

- THE NAME MUST BE <domain>.<entity>.<verb>.v<N> — lowercase, '-' or '_' inside a segment, at least two segments before the version. All 227 platform types follow it and registration rejects anything that does not.
- CHECK YOUR NAMES AGAINST `event-types.json` BEFORE YOU WRITE THEM. It carries every platform baseline name and the domains the platform owns. A name that collides is rejected 400 at registration; a name inside a platform-owned domain that does not collide YET becomes a collision the next time the platform adds a type, because the baseline is additive-only. Diff your vocabulary against it in CI, not at boot.
- THE .v<N> SUFFIX IS NOT DECORATION. It is what lets a payload shape change later as a NEW version instead of a silent redefinition of rows already written under the old shape. 'capture.created' is rejected; 'capture.lead.created.v1' is accepted.
- REGISTER BEFORE YOU APPEND. POST /api/events/types with a tenant JWT, once per type, typically from a boot-time provisioner. It is additive: a repeat returns 200 with the STORED metadata and created:false, so re-running it on every deploy is safe and correct.
- A 2xx FROM YOUR OWN WRITE PATH IS NOT PROOF THE EVENT LANDED. emitEvent catches and logs rather than propagating, so an audit outage never blocks the caller — which also means a PERMANENT rejection looks exactly like a transient blip. Verify against audit.entry.
- AN EMPTY CHAIN VERIFIES CLEAN. POST /api/audit/verify returning ok proves nothing if nothing was ever appended; check entries_checked, not just ok.
- retention_class IS LOAD-BEARING. When an append omits it, the REGISTERED type's class applies. Declaring 'operational' on something regulated shreds it at 90 days instead of seven years — quietly, and years later.
- YOU CANNOT SHADOW A PLATFORM TYPE, and should not try: resolution reads the baseline first, and registering a baseline name is rejected 400. Nor can you see another tenant's types, or they yours.

### Registering a type, then appending against it

```
POST /api/events/types  (tenant JWT)  -> 201 first time, 200 on a repeat
  { event_type: 'capture.lead.created.v1',
    retention_class: 'regulated',        // transient | operational | regulated
    conflict_policy: 'event-sourcing',   // crdt | lww | merge | event-sourcing | human-review
    schema_state: 'active',              // optional, default active
    compaction_policy: 'none',           // optional, default none
    schema_version: 1 }                  // optional, default 1
POST /api/audit/append  {pool_index, event_type, payload, actor_kind, tenant_id}  -> 201
  tenant_id defaults to your JWT's tenant claim; omit retention_class to inherit the type's.
POST /api/audit/verify  {pool_index}  -> assert entries_checked > 0, not just ok
GET  /api/events/types            -> platform baseline + your own (platform_count/tenant_count)
GET  /api/events/types/{type}     -> one type, with source: 'platform' | 'tenant'
```

Human guide: `docs/v3.1/developer-hub/audit-events.html`

## Files in this bundle

| File | Use it for |
| --- | --- |
| `AGENTS.md` | This file. Auth flows and the rules. |
| `sdk-catalog-index.json` | Capability discovery — which SDK covers X. Load this first. |
| `sdk-catalog.json` | Full per-endpoint spec, once you have a match. |
| `openapi.json` | OpenAPI 3.1 — point a client generator at it. |
| `event-types.json` | The 227 platform audit event names and the 59 domains the platform owns. Diff your own vocabulary against it before you ship. |

Human guide: `docs/v3.1/developer-hub/authentication.html`
