Skip to content
Docs menu

Effective profiles

The effective profile is everything that applies to one developer in one repo: standards, skills, commands, subagents, policy bundles, MCP endpoints, permissions, settings and telemetry switches, each with its why (node, mode, config version). Its shape is schema/profile.json.

Resolution

  1. Node: the repo registry (repos: in node.yaml, remotes normalised to host/org/repo), then the developer's default team (Entra group with default: true), then the tenant's default_node. The registry only counts for a node in the developer's own part of the tree (TEMPA-215): they hold a role on it or above it, it is (under) their default team, or one of their Entra groups is bound there. Naming another team's repo gets them their own default team's profile, never that team's policies or MCP credentials.
  2. Walk from the tenant down to that node, applying locked, extendable and open per setting (see config.md).
  3. Kill list entries are removed, whatever the config says.

The resolver is a pure function (api/tempa_api/profile/resolver.py).

GET /v1/profile

GET /v1/profile?harness=claude-code&repo=<git remote> with the CLI's device token (or an Entra token) returns the signed profile. repo is the raw remote (git@github.com:Devleaps/bridge.git works); leave it out outside a git repo. Response headers X-Tempa-Node and X-Tempa-Matched-By (repo, default_team, tenant_default, tenant) tell the CLI how the node was chosen.

Resolved profiles are cached in profiles by (user, repo, config version, harness). A new config version makes new cache keys; kill list changes drop the tenant's cache and a role change drops that user's. issued_at and expires_at are stamped per request from the tenant's profile_ttl; the CLI uses the cached profile offline until expires_at.

policies.wasm names the config version's policy bundles compiled to WebAssembly (sha256, size, abi, opa, entrypoints), or is null when the server has none; it is added per request, so it is signed with the rest. The module itself is at GET /v1/policy/wasm/{sha256} (policies.md).

Signature

  • Algorithm: Ed25519. Signed bytes: the canonical JSON of the profile without its signature field: object keys sorted, no whitespace, UTF-8, non-ASCII characters not escaped. In Python json.dumps(p, sort_keys=True, separators=(",", ":"), ensure_ascii=False); in TypeScript JSON.stringify over a copy with recursively sorted keys.
  • Signature field: {"alg": "Ed25519", "key_id": "<id>", "value": "<base64url, no padding>"}.
  • Public keys: in the discovery document, GET /.well-known/tempa.json → signing_keys[] with key_id, alg and the raw 32-byte public key in base64url. The CLI refuses a profile whose signature does not verify against a published key.
  • Cross-language fixture: schema/examples/signed-profile.json, signed with the well-known TEST key in schema/examples/signing-test-key.json (regenerate with scripts/make-signed-example.py). Both the API and the CLI test suites verify it.
VariableValue
TEMPA_SIGNING_PRIVATE_KEYEd25519 seed, 32 bytes base64url, from the cluster secret store
TEMPA_SIGNING_KEY_IDits id, for example tempa-2026-10
TEMPA_SIGNING_PUBLIC_KEYSJSON list of extra {key_id, public_key} to keep publishing during a rotation
TEMPA_SIGNING_KEY_ENDORSEMENTSJSON list of endorsements: a new key signed by the previous one (TEMPA-237, below)

Generate a key: cd api && uv run python -c "import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode())".

What the CLI trusts (TEMPA-237). tempa login pins the endpoint's published keys in ~/.tempa/pinned-keys.json (0600); from then on profiles are verified against the pinned keys, not against what the discovery document says today. A login from before pinning pins the keys it sees on its first sync. The signature also covers the profile's tenant and repo: the CLI refuses a profile signed for another tenant than the login's, or for another repo than the one it asked about.

Rotation: add the new key as the signing key with a new id, list the old public key in TEMPA_SIGNING_PUBLIC_KEYS until every cached profile signed with it has expired (profile_ttl), and publish an endorsement of the new key signed by the old one in TEMPA_SIGNING_KEY_ENDORSEMENTS (a JSON list; the discovery document's key_endorsements), or every CLI refuses the new key until its developer logs in again. Make it where the old seed is, with both seeds in the environment (never on the command line):

cd api && TEMPA_ENDORSE_PREVIOUS_KEY_ID=… TEMPA_ENDORSE_PREVIOUS_SEED=… \
  TEMPA_ENDORSE_NEW_KEY_ID=… TEMPA_ENDORSE_NEW_SEED=… uv run python -m tempa_api.profile.endorse

It prints {kind: "tempa-key-endorsement", key_id, public_key, signature} (signed like a profile). Keep the endorsement published as long as any CLI may still have only the old key pinned; dropping the old key's public half is fine once its profiles expired. A KMS for the signing key is an infrastructure follow-up.

Admin view

GET /v1/nodes/{id}/effective[?user=<email>] returns the unsigned profile a developer on that node gets, with the why trace, for anyone with profile.read on the node (tempa-web's effective profile page). Next to profile it returns sources: the config repo path of every asset in the profile (standards, skills, commands, agents, mcp, policies, keyed by name) and of every node on the way down (nodes, keyed by dotted path), so tempa-web can link each item to its file. GET /v1/nodes and GET /v1/nodes/{id} carry the node's file as source (tenant.yaml or nodes/.../node.yaml). It also returns coverage: per coding harness, whether each item is enforced, degraded (with the degrade rule) or only advisory, from the capability matrices (TEMPA-139, see harnesses.md). The MCP tool get_effective_profile returns the same body.

MCP sessions for the local proxy

tempa mcp (TEMPA-020) asks POST /v1/mcp/session with its device token and {"endpoint": "<name>", "repo": "<git remote or null>"} before it connects to an MCP endpoint of the profile. The answer {endpoint, url, headers, tools, expires_in} carries the upstream headers (for example the Orq MCP gateway key) that the proxy keeps in memory only, so no upstream token sits on a laptop. An endpoint that is not in the caller's profile for that repo is 403: a session in client A's repo cannot open client B's context. Upstream headers per endpoint come from TEMPA_MCP_CREDENTIALS (secret store), for example {"second-brain": {"Authorization": "Bearer <Orq key>"}}; endpoints with auth: none get no headers.