Skip to content

Adapter Requirements & Design Spec

This guide defines the requirements, contracts, and operational envelope for the adapter your team builds for its host system. The shiftagent Integration API the adapter consumes is documented in the API reference: the OpenAPI specification (API Reference) is normative for every API operation named here; this document references operations by operationId and never re-specifies request/response schemas.

What the adapter is, in one sentence: the adapter marries a host system to an on-prem shiftagent install — host JWT in, derived (external_tenant_id, external_user_id) identity, shiftagent Integration API calls out. The host never talks to shiftagent directly, and shiftagent never sees a host token.

Audience: the host-side engineering team building/operating the adapter, and shiftagent operators standing up the on-prem install it talks to.

Keywords: MUST / MUST NOT / SHOULD / MAY are used per RFC 2119. Where this document says “client-specific,” it means: varies per host system and is isolated behind a named seam (§3, §5.2); everything else is host-system-generic.

Related documentation:

DocumentRelationship to this spec
API ReferenceNormative API contract. Every operation cited here by operationId is defined there.
Integration GuideNarrative architecture, external-ID conventions, context-gathering pattern.
Provisioning FlowStep-by-step cold/warm walkthrough with sequence diagrams and race semantics.
Streaming ContractThe NDJSON agent event stream the adapter translates (§9.5).
Runtime ArchitectureComposable runtime: agent types, sandbox security model, warm sandboxes, filler/capacity semantics.

The adapter is deliberately characterized as a middleweight client — a precise middle ground between two shapes it must not become:

  • Not a thin proxy. It carries substantial logic: host-JWT verification and identity derivation, the JIT provisioning orchestration (§4), lifecycle reconciliation sweeps (§5), per-user token exchange and caching (§6), scoped-secrets handling (§7), and unbuffered NDJSON stream translation (§9.5). A naive header-rewriting reverse proxy cannot do this job.
  • Not a heavyweight middleware. It holds zero persistence — no database, no durable queue, no files, no shared cache (§2). It makes no business decisions: which skills a user gets, which repository a role uses, what an agent may do — all of that is configuration inside shiftagent. shiftagent’s external_id support on tenants, users, and conversations exists precisely so the adapter never needs a mapping table.
flowchart LR
    HOST["Host system<br/>(fronts all traffic)"] -- "host JWT per request" --> ADAPTER["Adapter<br/>(middleweight, stateless)"]
    ADAPTER -- "sk_int_ key +<br/>per-user platform JWT" --> API["shiftagent<br/>Integration API"]
    API --> STACK["Sandboxes, vault,<br/>repositories, storage"]
ConcernOwner
Validating the host JWT, deriving external IDsAdapter (the only client-specific logic — §3)
Storing tenant / user / role / conversation stateshiftagent DB, keyed by external_id on tenants, users, AND conversations
JIT provisioning decisions (“does this tenant/user exist?”)Adapter orchestrates; shiftagent enforces idempotency (PUT upsert semantics, DB uniqueness as the lock — §4)
Repository / role / skill semanticsshiftagent (top-level repository registry; one assigned repository per tenant, inheritable from ancestor tenants; roles narrow skills within it; effective skills resolved per request)
Rendering conversations, streaming to the host UIHost system, via the adapter’s translation of the NDJSON agent event stream
Secret valuesshiftagent vault — the adapter is a write-only conduit at all four scopes (§7)
Auth between adapter and shiftagentIntegration service key + per-user token exchange (§6)
Lifecycle truth (which tenants/users should exist)Host system; the adapter reconciles shiftagent to it (§5)

Explicitly out of scope for the adapter: any business logic; any persistence; any skill authoring; any decision about which skills a user gets (that is role configuration inside shiftagent, owned by the operator); any credential registry writes (§6.2).

The client’s shiftagent install carries one integration root tenant; every host tenant becomes a child tenant under it, carrying a namespaced external_id (§3.3). The adapter’s integration key is minted at that root and is subtree-scoped: it can provision and operate children but can never touch tenants outside its subtree — tenant isolation by construction, not by adapter diligence. The adapter discovers its root and scopes at startup via getIntegrationSelf (GET /integration/self), which returns root_tenant_id and the granted scopes. Confirm the one-root-per-install topology for your deployment (§11).

For any authenticated host user the adapter exposes, at minimum:

  • List conversations + historylistConversations (GET /conversations?user_id=) under the user’s exchanged platform JWT; tenant-wide administrative listing (GET /conversations?tenant_id=) under the service key.
  • Start or continue a conversation — the host’s router derives the external conversation ID deterministically from its own thread identifier (ticket, chat thread, call — namespaced per §3.3), and the adapter upserts by it: upsertConversationByExternalId (PUT /conversations/by-external-id/{external_id}) — exactly like tenants and users. 201 means a new conversation was created; 200 means the host thread already maps to one and it is continued. The adapter keeps no memory of conversation mappings — the external ID is the mapping. Pass initial_message to collapse continue-vs-create and the message send into one streamed round trip. On the create path shiftagent resolves the context snapshot (role → repository → skills); a user with no role assigned gets 422 role-required — ensuring the role is in place (the §4.4 re-entrancy rule) is an adapter duty. (createConversation, POST /conversations, remains available for host flows without a natural thread ID.)
  • Send messagescreateMessage (POST /conversations/{conversation_id}/messages), consuming the NDJSON agent event stream unbuffered and translating it into the host’s shape (§9.5). Message history retrieval via listMessages (GET /conversations/{conversation_id}/messages).

Runtime knobs (runtime.mode warm/pooled, on_capacity reject/hold, filler.enabled) are host-policy choices the adapter passes through verbatim; see Runtime Architecture for their semantics.


2. The zero-storage philosophy & cache policy

Section titled “2. The zero-storage philosophy & cache policy”

The adapter has no database, no durable queue, no files. Every mapping — host tenant → shiftagent tenant, host user → shiftagent user, host thread → shiftagent conversation — lives in shiftagent via external_id, reachable through getTenantByExternalId / getUserByExternalId / getConversationByExternalId and created through the corresponding PUT upserts. Consequences, stated normatively:

  • Adapter instances are interchangeable and horizontally scalable; any instance can serve any request with no session affinity.
  • Killing or redeploying the adapter loses nothing. Disaster recovery of the integration is disaster recovery of shiftagent alone.
  • There is no sync job and no drift between adapter state and shiftagent state, because the adapter has no state to drift. (Reconciliation in §5 diffs shiftagent against the host, not against any adapter store.)
  • Provisioning progress MUST NOT be recorded anywhere: the provisioning chain is convergent by construction (§4.4), so progress state is not just unnecessary — it is a correctness hazard.

Caching is allowed but must be in-memory, per-instance, bounded, and safe to lose. A shared cache (Redis or similar) is explicitly NOT introduced — it would be creeping storage and reintroduce the coordination problems zero-storage eliminates.

ItemCacheable?TTL / invalidationWhy
Host IdP JWKS keysYesPer Cache-Control (fallback 15 min); force-refetch once on unknown kidStandard OIDC practice; enables key rotation without restart
Exchanged platform JWT per (external_tenant_id, external_user_id)YesUntil exp − 60 s, hard cap 15 minRe-derivable via tokenExchange; bounds token-exchange QPS. NOTE: caps revocation latency — see §8
external_tenant_id → tnt_… resolutionYes≤ 5 min; drop on any 404/403 from shiftagentMapping is immutable once created; the short TTL only guards against tenant deprovisioning
external_user_id → usr_… resolutionYes (implied by the cached JWT entry)Same as the JWT entrySame
Capacity snapshot (getCapacity)Yes, advisory only≤ 5 sPoint-in-time pool state; never a substitute for handling 429 capacity-exhausted
Role definitions, skill grants, “skills this user can access”NoMust be live — shiftagent resolves capabilities and effective skills per request (listUserSkills, listRoleSkills)
User active/deactivated statusNoDeactivation must bite on the first request after the cached JWT expires; never cache an “active” verdict beyond the JWT TTL
Conversation lists / messagesNoConsistency-bearing data
Idempotency keys / provisioning progressNoProvisioning is convergent (§4.4); progress state is forbidden
Secret values (message, conversation, and user secrets payloads)NeverWrite-only pass-through into the vault (§7); MUST NOT be buffered beyond the in-flight request, logged, or cached

  1. The host system sends its own JWT on every request to the adapter: Authorization: Bearer <host_jwt>.

  2. The adapter validates, in order:

    • Signature against the host IdP’s JWKS, with an alg allow-list — asymmetric algorithms only (e.g. RS256, ES256, EdDSA). none and every HS* algorithm are rejected outright to prevent key-confusion attacks.
    • iss — exact string match against the configured issuer.
    • aud — contains the adapter’s registered audience.
    • exp / nbf / iat — enforced with ±60 s clock skew tolerance, no more.
    • Required identity claims present and non-empty (which claims those are is client-specific — §3.2).
  3. Key rotation: JWKS keys are selected by kid. An unknown kid triggers exactly one forced JWKS refetch before rejecting with 401; refetches are rate-capped (§8) so a garbage-kid flood cannot become a JWKS-fetch DoS.

  4. The adapter derives exactly two identity values, then discards the host JWT — it is never forwarded to shiftagent and never logged:

    • external_tenant_id — from the host’s tenant identifier claim
    • external_user_id — from the host’s user identifier claim

    Plus best-effort display attributes for provisioning enrichment: email, display_name. These are optional by design — shiftagent’s bare-minimum JIT contract means tenant and user creation succeed with nothing but the external_id; enrichment can arrive on any later request (PUT merge-upsert semantics, §4.3).

  5. Failure semantics: an invalid, expired, or absent host JWT → the adapter returns 401 (RFC 9457 application/problem+json, type …/host-token-invalid) without calling shiftagent at all.

3.2 deriveIdentity() — the single client-specific seam

Section titled “3.2 deriveIdentity() — the single client-specific seam”

Claim mapping is the ONLY client-specific code on the request path. It is a single pure function behind an interface:

deriveIdentity(verifiedClaims) → {
external_tenant_id, // required
external_user_id, // required
email?, // best-effort enrichment
display_name? // best-effort enrichment
}

Everything upstream of it (JWKS handling, validation) and downstream of it (provisioning, token exchange, forwarding) is host-system-generic. A wrong assumption about a host’s claim shape costs exactly one function. Per-client instantiations of this function live in each client’s own integration documentation.

External IDs are namespaced before they ever reach shiftagent:

external_tenant_id = "{ns}:tenant:{host_tenant_claim}"
external_user_id = "{ns}:user:{host_user_claim}"
external_conversation_id = "{ns}:conversation:{host_thread_id}"

where {ns} is adapter configuration (EXTERNAL_ID_NAMESPACE, §9.4). The first two come from host JWT claims via deriveIdentity() (§3.2); the conversation ID comes from the host’s router — its own thread/ticket/call identifier, taken from the request the host makes to the adapter — and MUST be derived deterministically so the same host thread always addresses the same conversation (§1.4). A second host system integrated later gets its own prefix, making collisions structurally impossible. Namespacing MUST be enforced from request one — retrofitting a namespace onto already-provisioned unprefixed IDs would require exactly the data migration the zero-storage design exists to avoid.

Illustrative only. Host IdP token shapes vary; real claim names, issuer, and algorithm are confirmed per client during adapter instantiation (see §11). This example exists to make the derivation concrete, not to specify any particular host system.

// Example host-IdP-issued JWT payload (namespace "acme" configured on the adapter)
{
"iss": "https://idp.host.example",
"aud": "shiftagent-adapter",
"sub": "user:29401", // → external_user_id = "acme:user:29401"
"org_id": "128231", // → external_tenant_id = "acme:tenant:128231"
"email": "dispatcher@acme-field.example",
"name": "Dana Dispatcher",
"iat": 1782046400,
"exp": 1782050000
}

Every host request enters the same machine. The provisioning primitive throughout is the PUT upsert by external IDupsertTenantByExternalId (PUT /tenants/by-external-id/{external_id}) and upsertUserByExternalId (PUT /tenants/{tenant_id}/users/by-external-id/{external_id}) — which returns 201 when it created the resource and 200 when it already existed. There is no GET-then-POST dance and no 409 on the tenant/user race path: the DB uniqueness constraint is the lock, and a concurrent loser simply receives 200 with the winner’s record. 409 + conflicting_resource_id recovery exists only for named sub-resources created with POST (roles via createRole, registry entries via registerRepository) — see §4.4.

stateDiagram-v2
    state "Verify host JWT" as Verify
    state "deriveIdentity()" as Derive
    state "Platform-JWT cache" as Cache
    state "PUT tenant by external ID" as UpsertTenant
    state "Assign repository (PUT)" as AssignRepo
    state "Ensure default role (POST)" as EnsureRole
    state "Adopt existing role via conflicting_resource_id" as FetchRole
    state "PUT user by external ID (role_id on create)" as UpsertUser
    state "Set role via updateUser (if role_id null)" as SetRole
    state "Token exchange" as Exchange
    state "Forward business call" as Forward
    state "Stream / respond" as Stream

    [*] --> Verify
    Verify --> Reject401 : invalid, expired, or absent
    Reject401 --> [*]
    Verify --> Derive : signature + claims verified
    Derive --> Cache
    Cache --> Forward : hit (steady state)
    Cache --> UpsertTenant : miss
    UpsertTenant --> AssignRepo : 201 tenant created (cold)
    UpsertTenant --> UpsertUser : 200 tenant existed (warm)
    AssignRepo --> EnsureRole : idempotent assign OK
    EnsureRole --> UpsertUser : 201 role created
    EnsureRole --> FetchRole : 409 name-conflict
    FetchRole --> UpsertUser
    UpsertUser --> Exchange : role_id set
    UpsertUser --> SetRole : role_id null (race)
    SetRole --> Exchange : PATCH updateUser
    Exchange --> Forward : platform JWT cached until exp-60s
    Exchange --> Reject403 : 403 user deactivated
    Reject403 --> [*]
    Forward --> Stream
    Stream --> [*]

A cached platform JWT exists for (external_tenant_id, external_user_id) → forward the business call (list conversations, send message, …) with that JWT. One shiftagent round-trip.

On a cache miss with everything already provisioned: upsertTenantByExternalId (200) → upsertUserByExternalId (200) → tokenExchange → forward — four round-trips, then cached. The two PUTs on the cache-miss path double as enrichment refresh: merge-upsert semantics mean the latest email / display_name from the host JWT flow into shiftagent on every cache-cold request without disturbing anything else (§4.3).

4.2 Cold path (JIT provisioning, ordered and convergent)

Section titled “4.2 Cold path (JIT provisioning, ordered and convergent)”

Triggered when upsertTenantByExternalId returns 201 (tenant newly created — body MAY be {}; external_id alone is sufficient). The adapter then runs the tenant bootstrap before continuing:

  1. Assign the repositoryassignTenantRepository (PUT /tenants/{tenant_id}/repository) with {repository_id}, pointing at the pre-registered registry entry named by adapter configuration (DEFAULT_REPOSITORY_NAME, resolved once via listRepositories). Idempotent PUT — re-running is a no-op 200. Each tenant has exactly one repository; deployments that assign it on an ancestor tenant (child tenants inherit the nearest ancestor’s assignment) skip this step entirely. The repository registry itself is operator-provisioned, pre-authenticated (createCredential + registerRepository at install time, §6.2); the adapter only assigns registry entries, never creates them.
  2. Ensure default rolecreateRole (POST /tenants/{tenant_id}/roles) with the well-known name from DEFAULT_ROLE_NAME and skill_access per configured policy (default {mode: "all"}). Role names are unique per tenant — deliberately load-bearing for replay-safe provisioning: a concurrent or repeated create returns 409 name-conflict carrying conflicting_resource_id, and the adapter adopts the existing role via getRole and continues. (listRoles with its ?name= filter serves the same lookup when recovering out-of-band.)
  3. Upsert user with its roleupsertUserByExternalId, body carrying enrichment (email, display_name) plus role_id referencing step 2’s role. Storage (the user’s S3-style bucket) is auto-attached by shiftagent. If the role could not be referenced (a racing replica has not created it yet), omit role_id and set it afterwards via updateUser (PATCH /users/{user_id}, {role_id}).
  4. Token exchange → forward (§6.3).

A new user in an existing tenant follows steps 3–4 only (the tenant PUT returned 200, so bootstrap is skipped).

4.3 Merge-upsert body discipline (do not wipe what you don’t own)

Section titled “4.3 Merge-upsert body discipline (do not wipe what you don’t own)”

The PUT upserts use merge semantics: provided fields replace, omitted fields are unchanged. This gives the adapter one hard rule:

On the warm path, the adapter MUST send only the fields it owns — enrichment attributes (email, display_name). It MUST NOT send role_id, storage, or metadata it did not set, or it will silently clobber operator-made assignments on every cache-cold request.

role_id therefore travels in the upsert body only on the cold path (§4.2 step 3, when the adapter is creating the user); omitting it on every warm-path refresh leaves the assignment exactly as the operator last set it. Deliberate role changes go through updateUser — an explicit administrative act, never a provisioning side effect. Operators remain free to change roles afterwards; the adapter never fights them.

  • Every step is idempotent or conflict-recoverable, and individually retryable. There is no transaction and no rollback. A partially provisioned tenant (e.g. tenant exists, role creation failed) is not an error state — a later pass re-enters the chain and converges.
  • Ordering is fixed — tenant → repository assignment → role → user (with its role) — so at every failure point the visible state is a strict prefix: never a user without a tenant, never a role assignment without a role.
  • Re-entrancy trigger: besides the 201-cold trigger, the adapter MUST treat downstream signals of incomplete bootstrap — 422 role-required on createConversation, a listRoles ?name= miss for the default role, or a user record with role_id: null — as a cue to re-run the bootstrap chain. Because every step is idempotent (PUT repository assignment, 409-recoverable role create, PUT user, PATCH role set), re-running from the top is always safe. This heals half-completed cold paths, including the case where the 201-winner crashed mid-bootstrap and a 200-loser is the next request to arrive.
  • Race semantics: concurrent JIT of the same tenant produces exactly one 201; every loser gets 200 with the winner’s record and proceeds. If a loser outruns the winner’s bootstrap and hits 422 role-required, the re-entrancy rule applies; the resulting duplicate createRole resolves via 409 name-conflict + conflicting_resource_id. No adapter-to-adapter coordination exists anywhere in this design.
  • Retries: idempotent GET/PUT calls are retried once on network error / 5xx with jittered backoff (100–300 ms). Non-idempotent POSTs carry the spec’s optional Idempotency-Key header — derived deterministically for provisioning steps (e.g. sha256(step ‖ external_tenant_id)), and a random UUID per user action for createMessage — so replays deduplicate server-side (24 h replay window, Idempotency-Replayed: true on replays).
FailureAdapter behaviorSurfaces to host as
shiftagent unreachable / 5xx after retryFail fast, no queuing (zero storage — there is nowhere safe to park a request)503 + Retry-After, problem type …/upstream-unavailable
Partial provision (crash mid-bootstrap)Nothing stored; the next request that needs the missing piece re-runs the chain (§4.4) and convergesTransparent (one slower request)
Concurrent JIT of the same tenant200-loser adopts winner’s record; role-create races resolve via 409 + conflicting_resource_idTransparent
User deactivated in shiftagenttokenExchange (or a forwarded call) returns 403 → drop cached JWT, do not re-provision — deactivated ≠ absent (§5.4)403, problem type …/user-revoked
Host JWT valid but tenant suspended/deleted in shiftagentDefault policy: refuse; re-provisioning on delete is a deliberate operator/policy decision, not automatic403, problem type …/tenant-suspended
Sandbox capacity exhausted (429 capacity-exhausted)Honor the host’s on_capacity choice: reject → propagate 429 + Retry-After; hold → pass through the stream’s queued platform notices429, or a stream that emits queued then proceeds
Rate limit from shiftagent (429 rate-limited)Propagate with Retry-After429
Streaming reply interruptedTerminate the translated stream; the mandatory terminal line (agent result or platform error) lets the consumer detect truncation; message history in shiftagent remains authoritativeTruncated stream; reconcile via listMessages
Run references an unvaulted secret alias (missing-secret)Surface the problem — which names the alias — to the host; the host re-sends the message with the secret attached (§7.3). Never invent or cache a valueStream terminal missing-secret error line, or 422 with ?stream=false

Provisioning is lazy (JIT, §4) — but deprovisioning cannot be: a tenant offboarded from the host, or a user removed there, must stop existing (or stop working) in shiftagent without anyone remembering to clean up. Three mechanisms compose, in increasing order of immediacy; the adapter implements all three, and none of them requires adapter storage:

MechanismLatencyRequiresRole
Periodic sweep (§5.1)Hours (cadence-bound)Host directory enumeration APISafety net — catches everything, eventually
Host webhook push (§5.3)SecondsHost lifecycle eventsOptimization — immediate, but lossy (webhooks get dropped)
Lazy enforcement (§5.4)Next requestNothingBackstop — deactivated identities can’t get tokens

A scheduled job (recommended: a Kubernetes CronJob running the adapter image in sweep mode — §9.2 — daily, off-peak by default; cadence configurable) that reconciles shiftagent’s view against the host’s system of record:

  1. Enumerate shiftagent — page listTenants (GET /tenants) with cursor pagination (starting_after, page size ~100) collecting every child tenant’s external_id; then, per tenant, page listTenantUsers (GET /tenants/{tenant_id}/users) collecting user external_ids. (Cross-tenant listUsers with ?tenant_id= filters is an equivalent alternative; per-tenant paging keeps memory bounded on large installs.)
  2. Enumerate the host — via the host’s directory API. This is the second and final client-specific seam after deriveIdentity(): a pair of functions listHostTenants() → external_tenant_id[] and listHostUsers(tenant) → external_user_id[] behind an interface, instantiated per client.
  3. Diff — strip the configured namespace prefix, compare sets.
  4. Deprovision what exists in shiftagent but not in the host:
    • tenant gone → deleteTenantByExternalId (DELETE /tenants/by-external-id/{external_id}) — or suspend-first per policy (§5.2)
    • user gone → deactivateUser (DELETE /users/{user_id})

Statelessness: the sweep keeps no checkpoint. Each run enumerates fully; an interrupted run simply leaves work for the next one. Duplicate concurrent sweeps are harmless (every deprovision call is idempotent) but wasteful — running the sweep as a single CronJob invocation rather than an in-process timer on every replica avoids them without leader election.

Guardrails (normative): a transient host-API failure that returns a partial or empty enumeration MUST NOT trigger mass deprovisioning. The sweep:

  • MUST abort without deprovisioning anything if host enumeration did not complete successfully end-to-end;
  • MUST abort (and alert) if the computed deletion delta exceeds a configured threshold (SWEEP_MAX_DELTA_PERCENT, default 10%) — a 40%-of-tenants-vanished diff is far more likely a host API incident than a real offboarding wave;
  • SHOULD emit a dry-run report metric/log line before acting, and support a --dry-run mode for operator rehearsal.

Hard deletion is irreversible and destroys conversation history, storage, and audit context. The default policy is soft first:

SubjectImmediate actionTerminal action
Tenant missing from hostupdateTenant (PATCH /tenants/{tenant_id}) → status: suspended — all activity stops at once (token exchange and business calls fail 403 tenant-suspended)deleteTenantByExternalId after a grace window (SWEEP_GRACE_DAYS, default 30) of consecutive sweeps still showing it absent
User missing from hostdeactivateUser — deactivation preserves conversations and audit trails while cutting accessHard user deletion is an operator decision, never an adapter action

Clients that require immediate hard deletion (e.g. contractual data-residency terms) MAY set SWEEP_DEPROVISION_MODE=delete, accepting the blast-radius trade-off; the guardrails in §5.1 still apply. Note deleteTenant/deleteTenantByExternalId are guarded server-side per the spec — deletion of a tenant with live dependents follows the spec’s documented semantics, not adapter improvisation.

5.3 Host webhook push (optional, if the host offers lifecycle events)

Section titled “5.3 Host webhook push (optional, if the host offers lifecycle events)”

If the host system can emit lifecycle events (“tenant removed”, “user deactivated”), the adapter exposes a webhook endpoint on its host-facing surface:

  • Authenticated by a host-signed webhook signature (shared secret or asymmetric, per host convention — WEBHOOK_SIGNING_SECRET); unauthenticated or badly-signed events are rejected 401 and never acted on.
  • Handler maps the event to the same idempotent calls the sweep uses (deleteTenantByExternalId / deactivateUser / suspend), applying the same suspend-first policy. Because the calls are idempotent, webhook + sweep overlap is harmless.
  • The webhook is an optimization for immediacy, never the mechanism of record — delivery is at-most-once from most hosts, so the sweep remains the safety net.

Host webhook availability and event vocabulary are deployment decisions (§11).

5.4 Lazy enforcement & the deactivated ≠ absent invariant

Section titled “5.4 Lazy enforcement & the deactivated ≠ absent invariant”

tokenExchange fails with 403 for a deactivated user, and the adapter maps that to 403 …/user-revoked, drops its cached JWT — and never re-provisions. This invariant is load-bearing:

  • Absent (no record for the external_id) → JIT provisioning applies; create away.
  • Deactivated (record exists, status: deactivated) → access was revoked; recreating the user via the upsert path would silently undo an offboarding. getUserByExternalId makes the distinction visible: it returns the record with its status rather than 404.

The adapter MUST check for this distinction wherever it might be tempted to provision: a 403 from tokenExchange or a deactivated status on the user upsert response is a terminal “revoked” state for that identity until an operator (or a host lifecycle event) says otherwise.


6. AuthN/AuthZ between adapter and shiftagent

Section titled “6. AuthN/AuthZ between adapter and shiftagent”

The adapter authenticates to the Integration API with a single integration service key (sk_int_…) — a service-principal credential minted at the integration root tenant, subtree-scoped (§1.3), held only in the adapter’s Kubernetes Secret and process memory. At startup (and on demand) the adapter introspects it via getIntegrationSelf to learn root_tenant_id and the granted scopes — failing readiness (§9.3) if the scopes don’t cover the table below.

Least-privilege scope set (what the runtime key needs, and why):

OperationsWhy the adapter needs them
upsertTenantByExternalId, getTenantByExternalId, updateTenant, listTenantsJIT tenant provisioning; sweep enumeration; suspend policy
deleteTenantByExternalIdSweep / webhook deprovisioning
listRepositories, getTenantRepository, assignTenantRepository, unassignTenantRepositoryResolve the configured default registry entry; bootstrap assignment
createRole, getRole, listRoles, listRoleSkillsBootstrap default role; 409 recovery; effective-skill surfaces
upsertUserByExternalId, getUserByExternalId, updateUser, listTenantUsers, listUsers, deactivateUserJIT user provisioning (incl. setting role_id); sweep; lazy enforcement
listUserSkills”Skills this user can access” convenience
tokenExchangePer-user context (§6.3)
listConversations (with ?tenant_id=)Tenant-wide administrative conversation listing
putConversationSecrets, listConversationSecrets, deleteConversationSecretWrite-only conversation-scoped secrets pass-through; alias listing (never values)
putUserSecrets, listUserSecrets, deleteUserSecretWrite-only user-scoped secrets pass-through; alias listing (never values)
getHealth, getCapacity, getIntegrationSelfReadiness probe; capacity pre-check; key introspection

Explicitly NOT granted to the runtime key: repository-registry writes (registerRepository, syncRepository, createRepositorySkill), credential-registry writes (createCredential, deleteCredential), API-key minting, and any governance/billing administration. Skill authoring is likewise out — skills come from repositories, not from the adapter.

Rotation: two keys may be live simultaneously (old + new) during rotation; the adapter reads the key from its Secret at startup and on SIGHUP/rolling restart, so rotation is a Secret update + rolling restart with zero downtime.

The one-time install-time setup — createCredential (git PAT, write-only) followed by registerRepository (name, URL, branch, credential reference) — is performed by the operator with a separate, short-lived bootstrap credential, not by the running adapter. This keeps raw secret material (the git PAT) out of the adapter’s steady-state privilege set entirely: at runtime, repositories are referenced by rep_… ID and credentials by crd_… ID; plaintext is never readable back through any API the adapter can call.

6.3 Per-user context — token exchange, not acting-as (decision record)

Section titled “6.3 Per-user context — token exchange, not acting-as (decision record)”
  • Chosen: the service key calls tokenExchange (POST /auth/token-exchange) with the derived external IDs, receiving a short-lived platform JWT (≤ 1 h issued; the adapter caches it for at most 15 min, §2.2) for that user. All user-context calls — conversations, messages, “my skills” — use that JWT. Every downstream audit record, skill resolution, and role check runs as the real user with zero special-casing; deactivation bites at the next exchange.
  • Rejected: service-key acting-as (an X-Act-As-User header on every call). It is a confused-deputy surface, requires trusted-header handling on every shiftagent route, weakens audit attribution, and diverges from the platform’s resolve-principal-from-the-bearer architecture.
  • Tenant-scope calls that have no user — tenant-wide conversation listing, provisioning, reconciliation — run under the service key directly, scoped to the resolved child tenant.

This section covers the adapter’s responsibilities for secret material at every scope. The unifying rule: the adapter is a write-only conduit — it forwards secrets into the vault and never sees them again.

Secrets are write-only alias → value maps, vaulted at the shiftagent boundary and resolved most-specific-first (message → conversation → user → tenant). The run sees only aliases ({{secret:CRM_API_KEY}}); the egress proxy resolves aliases to real values at the network boundary on outbound calls. No API response ever echoes a value. The adapter’s scope guidance to the host:

ScopeVaulted viaUse when
Messagesecrets on createMessageThe credential should live for exactly one run — host-injected per request, nothing persisted
ConversationputConversationSecrets (PUT /conversations/{conversation_id}/secrets)The credential serves one thread’s work; destroyed on archive and tenant deprovision
UserputUserSecrets (PUT /users/{user_id}/secrets)The credential belongs to the person — their own CRM login, warehouse account — and should follow them across conversations
TenantThe credential registry (createCredential) — operator-managed, not an adapter write (§6.2)Shared system credentials: git tokens, service accounts

Prefer the narrowest scope that serves the need — a message-scoped secret leaves nothing behind; a user-scoped secret saves the host from re-sending on every message. Alias inventories (listConversationSecrets, listUserSecrets) let host UXes show what is vaulted without ever being able to show the values.

7.2 The write-only conduit rules (normative)

Section titled “7.2 The write-only conduit rules (normative)”

Two distinct channels ride on createMessage (with putConversationSecrets / putUserSecrets for out-of-band management):

  • env — plaintext, non-secret run parameters. The adapter MUST document to the host — loudly, as the OpenAPI spec itself does — that env is not a secrets channel: values in env are visible to the run.
  • secrets — write-only alias → value pairs, vaulted at the shiftagent boundary at the scope of the carrying operation.

Adapter duties on this path:

  • Forward secrets payloads (message bodies, putConversationSecrets, putUserSecrets) verbatim and write-only: never logged (structured-logging redaction on the secrets field by name, plus authorization/token — §8), never persisted, never cached beyond the in-flight request buffer, never echoed in any adapter response or error detail.
  • Never transform, inspect, or validate secret values — the adapter has no business knowing what they are. (Client-specific hardening — e.g. host-side pattern checks that env doesn’t carry obvious secret material — belongs in the adapter layer per the hardening philosophy in §8, but operates on the env channel, not by reading secrets.)

A run that references an alias vaulted at no scope fails fast: the stream terminates with a missing-secret platform error line naming the alias (?stream=false422). There is no mid-run pause — the failed run’s messages are in history, and a re-sent message starts a fresh run that resumes the same session. Adapter duties:

  • Surface the problem, with the alias, to the host — pass the missing-secret problem through body-intact (§10) so the host knows exactly which credential to supply.
  • Support the one-step retry: the host re-sends the message with the secret attached — in message.secrets for a one-shot value, or staged first at a longer-lived scope (§7.1) when the credential will be needed again. The adapter forwards it write-only, as always.
  • Never satisfy the retry itself. The adapter holds no secret material (§2.2) and MUST NOT acquire any to “help” — supplying credentials is the host’s (or its user’s) decision, made in the host’s own UX.

This composition preserves the zero-trust guarantee end to end: the agent can name what it needs but never holds a value; the host supplies values need-to-know; the adapter transports them write-only; the egress proxy is the only component that ever resolves an alias.


Hardening philosophy (normative): anything client-specific that needs hardening — host-quirk validation, extra rate shaping, bespoke audit hooks, webhook signature schemes — happens in the adapter layer. shiftagent stays generic; its “spiritually aligned” extension points are vaulted credentials and custom skills, not host-specific code paths.

  • No token leakage into logs: host JWT, exchanged platform JWTs, and the sk_int_ key are never logged and never echoed in problem responses; structured-logging redaction on authorization / token fields. The service key exists only in the K8s Secret and process memory.
  • No secret-value logging anywhere on the path: secrets maps (message, putConversationSecrets, putUserSecrets) are redacted by field name at the logging layer and excluded from request-body capture, error reports, and traces (§7.2).
  • Host JWT is never forwarded to shiftagent; platform JWTs are never returned to the host. Each trust domain sees only its own tokens.
  • external_id namespacing: all IDs prefixed with the configured namespace at derivation (§3.3); a future second integration gets its own prefix — collisions structurally impossible.
  • Tenant isolation: external_tenant_id comes ONLY from the verified host JWT — never from a request path, body, or query parameter; the service key is subtree-scoped to the integration root; cross-tenant conversation access is prevented by shiftagent’s per-user JWT scoping, not by adapter diligence.
  • Replay stance: the stateless adapter keeps no jti cache by design; mitigations are TLS everywhere, short host-JWT exp, ±60 s skew only, exchanged JWTs capped at 15 min. Strict replay-proofing beyond that is the host IdP’s job (short-lived tokens) — documented as a shared-responsibility line.
  • Revocation latency budget: worst case = the cached platform-JWT TTL (15 min), stated as an explicit SLO. If the client needs immediate cutoff, the adapter exposes an optional cache-purge admin endpoint (POST /admin/evict {external_user_id}) — still zero durable storage (§11).
  • Rate limits: per-external_user_id token bucket in the adapter (in-memory, best-effort, per-instance) + transparent pass-through of shiftagent 429s with Retry-After.
  • Algorithm pinning & JWKS hygiene: asymmetric algorithms only, pinned issuer, JWKS over TLS with certificate validation, refetch-on-unknown-kid with a refetch rate cap (anti-DoS).
  • Webhook authentication: host lifecycle webhooks (§5.3) verified against their signature before any deprovisioning action; unauthenticated events dropped and counted.
  • Sweep guardrails armed: incomplete host enumeration aborts the sweep; delta threshold enforced; dry-run rehearsed before first production sweep (§5.1).
  • Request tracing: the adapter generates/propagates X-Request-Id end-to-end; Integration API problem responses carry request_id, which the adapter preserves in its host-facing problems for cross-system correlation.
  • No raw secrets in provisioning payloads: repositories are assigned by pre-provisioned rep_… ID referencing an operator-registered crd_… credential — raw git PATs never transit the adapter (§6.2). The only secret material that ever transits is the write-only scoped-secrets channel (§7).

9.1 Packaging: gateway service (decision record)

Section titled “9.1 Packaging: gateway service (decision record)”
OptionVerdictWhy
Library embedded in the host codebaseRejectedCouples release cadence to the host’s deploy train; the host team would have to hold the shiftagent service key; claim-mapping updates would need host redeploys
Sidecar per host podRejectedThe host is typically a large multi-service system, not one pod; N sidecars = N key copies and N JWKS caches for zero isolation gain
Standalone gateway serviceChosenOne deployable, one key, one place to rotate and observe; stateless → trivially HA (≥ 2 replicas, HPA on CPU); matches the zero-storage philosophy

The adapter deploys into the client’s cluster alongside the existing shiftagent Helm install — a sibling Deployment + Service in the same namespace (optionally packaged as a subchart), exposed to the host network only (Ingress or private link). The reconciliation sweep (§5.1) runs as a CronJob invoking the same image in sweep mode.

flowchart LR
    subgraph hostnet["Host network"]
        HOSTAPP["Host application / UX"]
        IDP["Host IdP (JWKS)"]
        DIRECTORY["Host directory /<br/>lifecycle events"]
    end
    subgraph cluster["Client's on-prem Kubernetes cluster"]
        subgraph ns["shiftagent namespace (Helm release)"]
            ADAPTER["Adapter Deployment<br/>stateless, ≥ 2 replicas"]
            SWEEP["Adapter CronJob<br/>(reconciliation sweep)"]
            API["shiftagent Integration API"]
            VAULT["Vault + egress proxy<br/>(alias resolution)"]
            POOL["Sandbox pool<br/>(warm + pooled)"]
            PG[("Postgres")]
        end
    end
    HOSTAPP -- "host JWT" --> ADAPTER
    ADAPTER -- "JWKS fetch (cached)" --> IDP
    DIRECTORY -. "lifecycle webhook (optional)" .-> ADAPTER
    ADAPTER -- "sk_int_ key / platform JWT" --> API
    SWEEP -- "listTenants / listUsers diff" --> API
    SWEEP -. "enumerate live tenants + users" .-> DIRECTORY
    API --> PG
    API --> POOL
    POOL --> VAULT
  • /healthz — liveness: the process is up.
  • /readyz — readiness: JWKS reachable (or cached), getHealth (GET /health) answering, and getIntegrationSelf scope check passed (§6.1). Not-ready instances are rotated out by the Service without any state loss — there is none to lose.
VariablePurposeDefault
SHIFTAGENT_BASE_URLIn-cluster Integration API base URL (Service DNS)— (required)
SHIFTAGENT_API_KEYThe sk_int_ integration key (from a K8s Secret)— (required)
HOST_JWKS_URLHost IdP JWKS endpoint— (required)
HOST_ISSUERExact iss to require— (required)
HOST_AUDIENCERequired aud value— (required)
EXTERNAL_ID_NAMESPACENamespace prefix for derived external IDs (§3.3)— (required)
DEFAULT_REPOSITORY_NAMERegistry entry attached as each new tenant’s default (§4.2)— (required)
DEFAULT_ROLE_NAMEWell-known role slug ensured per tenanthost-default
DEFAULT_ROLE_SKILL_ACCESSBootstrap role’s skill_access policyall
TOKEN_CACHE_TTL_SECONDSPlatform-JWT cache hard cap (§2.2)900
TENANT_CACHE_TTL_SECONDSexternal→internal tenant-ID cache TTL300
JWKS_CACHE_TTL_SECONDSJWKS fallback TTL when no Cache-Control900
SWEEP_DEPROVISION_MODEsuspend-then-delete | delete (§5.2)suspend-then-delete
SWEEP_GRACE_DAYSSuspend → delete grace window30
SWEEP_MAX_DELTA_PERCENTSweep abort threshold (§5.1)10
WEBHOOK_SIGNING_SECRETVerifies host lifecycle webhooks (§5.3)— (optional; webhook disabled without it)
ERROR_TYPE_BASE_URLBase URI for host-facing RFC 9457 type values (§10)— (required)
UPSTREAM_TIMEOUT_MSPer-call Integration API timeout (non-streaming)10000
STREAM_IDLE_TIMEOUT_MSMax silence on a pass-through stream before terminating120000

No config files, no flags, no runtime-mutable settings — the config surface is the environment, which keeps instances interchangeable and rotation auditable.

9.5 Streaming translation (explicit, because it silently breaks)

Section titled “9.5 Streaming translation (explicit, because it silently breaks)”

The NDJSON stream is the product surface the host user actually feels; a naive proxy config breaks it invisibly. The stream is the platform’s native agent event stream — raw agent runtime events plus platform notice lines, versioned by X-Shiftagent-Stream-Format — and translating it into whatever the host needs is the adapter’s job: reduce it to text deltas for a chat UI, mirror tool activity into progress indicators, or pass it through verbatim to a host that speaks the vocabulary itself. Normative guidance:

  • Process per line, flush per line. The adapter consumes each NDJSON line as it arrives and emits its translated output immediately — no response buffering, no compression that introduces buffering (disable gzip on the streaming route or use flush-friendly settings).
  • Disable buffering on every hop the client controls: ingress annotations (e.g. proxy-buffering: off for NGINX-class ingresses), any service mesh, and the adapter’s own HTTP framework defaults.
  • Timeouts must exceed the semantics: idle timeouts on the streaming path must accommodate the documented max hold time for on_capacity=hold (queued notices and {"event":"ping"} keepalives count as traffic).
  • Ignore unknown lines; never fabricate known ones. New event types are additive — the translator skips what it doesn’t recognize. Exactly one terminal line (the agent result event or a platform error line) ends every complete upstream stream; on upstream failure without a terminal line, the adapter terminates its output and signals truncation to the host — it must not synthesize a terminal it didn’t receive. Reconcile via listMessages.
  • Non-streaming mode (?stream=false on createMessage) is the fallback for host paths that cannot consume streams; the adapter exposes both.

Metrics (Prometheus-style; no payload contents anywhere):

  • adapter_requests_total{route,status} / adapter_request_duration_seconds
  • adapter_upstream_latency_seconds{operation_id}
  • adapter_provision_steps_total{step,outcome} — cold-path visibility per §4.2 step
  • adapter_token_exchanges_total{outcome}, adapter_cache_events_total{cache,hit|miss}
  • adapter_stream_lines_total{type} — per line family, including queued notices and terminal error lines (e.g. missing-secret)
  • adapter_sweep_last_success_timestamp, adapter_sweep_deprovisioned_total{kind,action}, adapter_sweep_aborts_total{reason}
  • adapter_webhook_events_total{type,outcome}

Logs are structured, with the redaction set from §8 applied globally. Traces (optional) propagate X-Request-Id into the Integration API and back out to the host.


All adapter-originated errors are RFC 9457 application/problem+json (house style), with type URIs under the configured ERROR_TYPE_BASE_URL. Adapter-originated types:

Type (suffix)StatusWhen
host-token-invalid401Host JWT absent, expired, bad signature, wrong iss/aud, or missing identity claims (§3.1). shiftagent is never called.
user-revoked403tokenExchange or a forwarded call reports the user deactivated; cached JWT dropped; no re-provisioning (§5.4)
tenant-suspended403The tenant is suspended or deleted in shiftagent and policy says refuse (§4.5, §5.2)
upstream-unavailable503Integration API unreachable / 5xx after the retry budget; carries Retry-After (§4.5)
rate-limited429Adapter’s own per-user token bucket tripped, or pass-through of shiftagent rate-limited (with Retry-After)

Example body:

{
"type": "https://errors.adapter.example/upstream-unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "The integration API did not respond after retries.",
"request_id": "req_8f14e45fceea"
}

Pass-through policy for business calls: Integration API 4xx problems — validation-error (422), not-found (404), name-conflict / external-id-conflict / cross-tenant / conversation-archived / resource-in-use / idempotency-key-conflict (409), role-required (422), missing-secret (422), insufficient-scope (403), capacity-exhausted (429) — are passed through to the host body-intact (they contain no internal secrets by contract and carry the request_id needed for cross-system support), except where a lifecycle rule maps them (the 403s that become user-revoked / tenant-suspended above, and the provisioning 409s the adapter consumes internally per §4.4 and never surfaces). capacity-exhausted handling follows the host’s on_capacity choice (§4.5); missing-secret drives the one-step retry (§7.3).


Resolve these items with your shiftagent operator before go-live — each one pins a configuration value or a client-specific seam in the adapter you build:

  1. Host IdP token sample. The §3.4 example is illustrative. Obtain a real token from your host IdP (issuer, algorithm, exact claim names for tenant and user) to pin deriveIdentity() — the mapping is isolated behind one function precisely so a wrong assumption costs one function, but it must be confirmed before go-live.
  2. Revocation latency requirement. Decide whether the 15-minute cached-platform-JWT window (§2.2, §8) meets your offboarding requirements, or whether the cache-evict admin endpoint must be part of your adapter from day one.
  3. Host webhook availability & event vocabulary. Determine whether the host system emits lifecycle events the adapter can subscribe to for push deprovisioning (§5.3), and with what event types, delivery guarantees, and signature scheme. Without it, the sweep + lazy enforcement pair is the whole story.
  4. Integration-root topology. Confirm one integration root tenant per install, with every host tenant as a direct child (§1.3) — this determines the service key’s subtree scope and the sweep’s enumeration boundary.
  5. Host directory enumeration for the sweep. Identify the API the host exposes for listHostTenants() / listHostUsers() (§5.1) — completeness guarantees, paging, and rate limits determine sweep cadence and guardrail tuning.
  6. Secret-scope policy. Decide which credentials the host supplies at which scope (§7.1) — what rides per message, what is staged per conversation or per user, and what the operator registers in the tenant credential registry — and how the host UX handles the missing-secret retry (§7.3).