@temper in Slack and get an answer computedEvery claim here is cited to code. Where a behaviour is defended by a test or a CI guard, the
guard is named. Where a mitigation lives outside this repo, it says so. Where something is
best-effort, it says best-effort.
| Symptom | Most likely cause | Where to look |
|---|---|---|
Every mention 401s at temper | SLACK_LINK_SECRET differs between temper-mention and temper-cloud, or is unset on either | crates/temper-api/src/middleware/internal_auth.rs:123-141 (fail-closed on unset) |
Mentions resolve, but the answer never comes and the log shows a mint 401 | SLACK_MINT_SECRET set but not byte-identical on both projects — a clipped trailing = does this. It is an opaque string, never decoded | internal_auth.rs:82; packages/agent-workflows/mention/agent/lib/link.ts:20 |
| An env var was changed and nothing changed | Vercel does not rebuild on an env change — redeploy | Deployment |
| Mentions work; the bot says it can't answer | SLACK_MINT_SECRET unset ⇒ the mint route is disabled but the link flow is fine | internal_auth.rs:166-179; crates/temper-services/src/config.rs:181 |
| The whole link flow says "Account linking is not configured" | One of the four link vars missing, or SLACK_VAULT_ENC_KEY malformed | config.rs:204-225 — all-or-nothing, and a bad key logs loudly then disables |
| A user is "connected" but every mention says re-link | Linked with no vaulted grant ⇒ mint answers not_vaulted | crates/temper-api/src/handlers/slack_mint.rs NotVaulted arm |
| "Already connected to a different temper account" | The no-rebind guard fired; the principal is bound elsewhere | crates/temper-services/src/services/slack_link_service.rs:168-226 |
| A disconnected user still seems to have access | Expected. See Revocation — this is not a bug | crates/temper-api/src/middleware/auth.rs:49-102 |
| Agent function dies at import | TEMPER_MCP_URL unset — read at module load, not per-request | packages/agent-workflows/mention/agent/connections/temper.ts:35 |
principalId for every inbound Slack message. It has four shapes, because the teampackages/agent-workflows/mention/agent/lib/identity.ts:10-25):| team id | author | principalId | principalType |
|---|---|---|---|
| yes | human | slack:<team>:<user> | user |
| yes | bot | slack:<team>:bot:<user> | service |
| no | human | slack:<user> | user |
| no | bot | slack:bot:<user> | service |
crates/temper-api/src/handlers/slack_link.rs:126-150):Three checks, all shape and none semantic: non-empty, within the storage column's width, and
carrying theslack:prefix. The principal is OPAQUE — 2 to 4 segments … so it is deliberately
NEVER split on ':'. A prefix check plus a length check is the whole of what is knowable without
parsing something we have no business parsing.
accepts_every_shape_of_real_principal (slack_link.rs:527-536) asserts all four shapes pass — aattributes.user_id, never a parse (agent/channels/slack.ts:83-86).kb_profile_auth_links(auth_provider = 'slack', auth_provider_user_id = <the whole principal>) →
profile_id, with UNIQUE(auth_provider, auth_provider_user_id)migrations/20260624000001_canonical_schema.sql:331-340). The column is VARCHAR(128), which isslack_link.rs:36-40) — rejects_a_principal_wider_than_the_storage_columnslack_link.rs:544) pins that boundary.migrations/20260717000030_slack_grant_vault.sql:6-7). Identity andWHERE on theON CONFLICT ... DO UPDATE, so a different-profile attempt matches zero rows and returns a refusalslack_link_service.rs:198-226):AlreadyLinkedToAnotherProfile, nothingtemper slack disconnect, never a side effect of linkingauthor_type, channel_id, thread_ts,user_id, plus optional user_name, full_name, team_idpackages/agent-workflows/mention/CLAUDE.md, "THERE IS NO EMAIL", verified againstbuildSlackAuthContext). No email field exists to read.JSON.stringify({ slack_principal_id: principalId }) (agent/lib/link.ts:53,agent/lib/mint.ts:56). A grep for email across the agent's agent/, tests/, and manifestemail NULL, and says whyslack_link_service.rs:189-190):
on the opaque principal.
authenticate_token_existing_only, never authenticate_tokenslack_link.rs:339-387):the latter auto-provisions a profile, which on a stray click would mint an account and confer
auto-join team reach. Linking an existing identity is not a registration route.
callback_with_an_unknown_identity_creates_no_profiletests/e2e/tests/slack_link_test.rs:455) defends this.| Credential | Authenticates | Reach it confers in temper |
|---|---|---|
Slack bot token (SLACK_BOT_TOKEN) | eve → the Slack Web API | None. temper never sees it; temper-api holds no Slack credential and knows no channel (slack_link.rs:159-163). |
SLACK_SIGNING_SECRET | Slack → eve (inbound webhook HMAC) | None. It gates entry to the agent, not to temper. |
SLACK_LINK_SECRET HMAC | request integrity, agent → temper on /internal/slack/link-state | None — it is not an identity at all. It authenticates the call, not a person. The endpoint answers one question: "is this principal linked, and what do I say?" |
SLACK_MINT_SECRET HMAC | request integrity, agent → temper on /internal/slack/mint | None itself — but it gates the row below. Possession is the only thing that makes a named principal mintable. |
| A minted per-user access token | the linked human | That human's ENTIRE reach. resources_visible_to takes a profile and nothing else — there is no narrowing behind it. Whoever holds the token is, to temper, that person. |
crates/temper-api/src/middleware/internal_auth.rs:143-165):This is the highest-privilege gate in the file, and the reason it has its own key. The other
two guard endpoints that report something … This one guards an endpoint that hands back an
act-as-the-human access token … The endpoint that confers reach cannot share a key with
one that merely answers a question, however convenient one variable would be. Same scheme, third
key.
slack_mint_secret is therefore deliberately not a field on SlackLinkConfig, for twocrates/temper-services/src/config.rs:45-61): the privilege asymmetry above,parse_slack_link is all-or-nothing, so folding it in would make a deploy that hasmint_access_token enforces noslack_grant_vault_service.rs:233-236). The rule "naming a principal must not be sufficient to"slack:T123:U456", no function can tell whether it was read off a Slack-signed webhook or typedslack_mint_service.rs:8-41). The transport gate is the whole enforcement.A consequence worth internalising: a test that calls mint_for_mentiondirectly and passes has
proved nothing about authorization. The test must drive the route.
.github/scripts/audit-signature-secrets.sh asserts each gate reads a distinct secretUPDATE_BASELINE cannotCollapsing two of these onto one config field is a one-line edit that no type checks, no route
audit notices (all three layers are still mounted, soaudit-route-auth.shstays green), and —
until this script — nothing in CI caught. It was defended only by an e2e test, i.e. only where
someone remembered to look.
tests/mint.test.ts:42-56 asserts the mint call's signature equalsSLACK_LINK_SECRET stubbedmint_refuses_the_link_state_key (tests/e2e/tests/slack_link_test.rs:1506) drives the real route.[Slack workspace] ← untrusted; anyone in the workspace can type anything
│ POST /eve/v1/slack
│ ── verified by: Slack request signature (SLACK_SIGNING_SECRET), HMAC over the raw body
▼
[eve runtime, in the mention agent] ← boundary 1
│ verifyInbound() is the FIRST statement of the route; failure ⇒ 401 before any dispatch
│ principalId is derived HERE, by eve, from the verified event
▼
[agent handler: onAppMention] ← boundary 2 (policy, not authentication)
│ decideIdentity(): principalType === "user" or drop; bots surface as "service"
│ body = { slack_principal_id } and NOTHING else
│ ── signed with: HMAC-SHA256(secret, "{timestamp}.{body}") → X-Temper-Signature
▼
[temper-api /internal/slack/{link-state,mint}] ← boundary 3
│ require_slack_link_signature | require_slack_mint_signature
│ fresh timestamp (±30s) + constant-time MAC over the exact bytes received
▼
[services → DB] ← boundary 4
resources_visible_to / can_modify_resource scope every query to the profileSLACK_SIGNING_SECRET throws, is caught, and the route401. The url_verification branch lives inside handleEventPost, reached only afterpackages/agent-workflows/mention/slack-app-manifest.yml:10-14). This isdefaultSlackAuth(message, ctx)agent/lib/identity.ts:88-92). The human gate is writtenprincipalType === "user", not !== "service" — so a principal type eve adds later isidentity.ts:71-73).HMAC-SHA256(secret, "{timestamp}.{body}"), lowercase hex, inX-Temper-Signature with X-Temper-Timestamp (crates/temper-core/src/internal_sig.rs:26-36).verify_slice, internal_sig.rs:53-63)MAX_SKEW_SECS, internal_sig.rs:36).require_auth does full JWKS validation and every query scopes through the standard visibility/api/auth/slack/callback) whose compensating control isslack_link.rs:196-204). The link URL handed to the user is the IdP's own authorize URL, not aslack_link.rs:77-80)..github/scripts/audit-route-auth.sh, which freezes| # | Behaviour | Where |
|---|---|---|
| 1 | Unset SLACK_LINK_SECRET disables the link-state endpoint. No secret ⇒ every request rejected. | internal_auth.rs:123-141 |
| 2 | Unset SLACK_MINT_SECRET disables the mint endpoint — and only that. An instance can legitimately run with linking on and minting off. | internal_auth.rs:166-179; config.rs:181 |
| 3 | parse_slack_link is all-or-nothing. Some only when all four values are present, non-empty, and the vault key parses. A partial set is unconfigured, not half-configured. | config.rs:200-225 |
| 4 | A malformed SLACK_VAULT_ENC_KEY disables the whole link flow with a loud error, rather than booting a flow whose vault writes would fail at the callback. | config.rs:207-217 |
| 5 | No config ⇒ minting is impossible, not merely unconfigured. Without the vault key there is no key to unseal a grant with. | slack_mint_service.rs:64-67 |
| 6 | The callback is one transaction. Identity row and sealed grant commit together or not at all — a half-write was unrecoverable, because the state nonce is already burned. | slack_link.rs:225-238, :329-332 |
| 7 | No refresh token ⇒ the link is rolled back, and the page does NOT render success. It used to warn and render "Account connected" at a user whose link was inert. | slack_link.rs:279-311 |
| 8 | getTemperToken fails closed on every non-token outcome, including an unrecognised fourth status — the never binding makes a new variant a compile error, and the runtime arm covers a server that ships one before the agent redeploys. | agent/lib/mcp-auth.ts:107-157 |
| 9 | requireEnv treats "" as missing, so an empty-string secret throws rather than signing with an empty key. | agent/lib/link.ts:78-82 |
| 10 | eve's inbound verification fails closed on a missing secret, not just a bad signature. | slack-app-manifest.yml:10-14 |
| 11 | DMs are explicitly refused (onDirectMessage: async () => null). Leaving the key absent would inherit eve's default, which dispatches unconditionally — no identity gate, no link-state, no mint pre-flight. | agent/channels/slack.ts; mention/CLAUDE.md |
| 12 | The tool allow-list is read-only and is the enforcement point. Nine names. Writes are absent deliberately: a read-only context member can currently create a resource in that context, so a write tool would exercise that bug under a real human's whole reach. | agent/lib/mcp-auth.ts:46-74 |
mcp-auth.ts:142-146): falling out of the switch would return undefined where eve expects aTokenResult, and "the connection would then call the MCP server with no credential, which is themint_is_disabled_without_its_secret_but_linking_still_workstests/e2e/tests/slack_link_test.rs:1748) defends #2 end to end.TEMPER_MCP_URL is new with the agent half and is required: it is read at module load byagent/connections/temper.ts:35, so an unset value fails the whole function at import rather thangetTemperToken and the allow-list live in agent/lib/mcp-auth.ts —⚠️ This is the pattern-match hazard in this feature. Two SLACK_*secrets have opposite
deployment rules, and they look alike enough that treating them the same way is the natural
mistake. Read this before touching either.
| Secret | Rule | Why |
|---|---|---|
SLACK_VAULT_ENC_KEY | MUST be set as part of the deploy that ships the vault — not after. | parse_slack_link is all-or-nothing (config.rs:204-225). Deploying vault code to an instance already running the link flow, without the key, turns the link flow off. |
SLACK_MINT_SECRET | MUST NOT be set until its caller ships. | Setting it early makes a live act-as-any-linked-human endpoint reachable with no legitimate consumer. That is exposure with zero upside. The endpoint ships dark by design (config.rs:57-60, internal_auth.rs:163-165). |
The server half is temper-cloud, nottemper-api. Those name a crate and two different
Vercel projects, and they are not the same target:
Vercel project What it is Slack? temper-cloudthe temperkb.io community deployment Yes — every Slack variable below goes here temper-mentionthe @tempermention agent (packages/agent-workflows/mention)Yes — the agent-side variables temper-apithe enterprise deployment No — no Slack piece is deployed here yet "Deploy temper-api" is the right sentence about the crate and the wrong one about the
environment. Set Slack variables on temper-cloud.Vercel does not rebuild on an environment-variable change. After setting any of these,
trigger a redeploy or the running function keeps the old values — and a build-time variable likeTEMPER_MCP_URLwill keep failing until you do.
SLACK_VAULT_ENC_KEY. Linking works;TEMPER_API_URL, SLACK_LINK_SECRET, TEMPER_MCP_URL. MentionsSLACK_MINT_SECRET on both — byte-identical, and different fromSLACK_LINK_SECRET.On generating SLACK_MINT_SECRET:openssl rand -base64 32is right, but note it is an
opaque string —verify(secret.as_bytes(), …)(internal_auth.rs:82) andcreateHmac("sha256", secret)(link.ts:20) both consume it raw. It is never base64-decoded,
so the trailing=is part of the secret, not encoding to strip.Do not carry this reasoning to SLACK_VAULT_ENC_KEY, which is decoded
(VaultKey::from_base64,config.rs:207) and must be exactly 32 bytes. Two base64-looking Slack
secrets, only one of them actually base64. A mismatched mint secret is a 401 on every mention,
silent — not a warning, not a degraded mode.
SLACK_MINT_SECRET first, which cleanly disables minting whiletemper slack disconnect (self-serve) and temper admin slack disconnect <principal> (systemslack_disconnect_service.rs:262-278) both run one chokepoint. Here is what each effect costs in| Effect | Latency | Mechanism |
|---|---|---|
| Vault row deleted; cached AT and RT destroyed locally | 0 | DELETE FROM kb_slack_grant_vault in the disconnect transaction (slack_disconnect_service.rs:160-167) — the row is deleted, not flagged |
Next mint answers not_vaulted | 0 | No row ⇒ MintOutcome::NotVaulted (slack_grant_vault_service.rs:260-262) |
| Link intents for that principal swept | 0 | slack_disconnect_service.rs:216-222 — load-bearing, see Residual risks |
| An already-issued access token stays valid at temper's API | up to its full remaining TTL | See below |
| eve's per-user token cache | bounded by the same TTL | Cache is eve's, keyed user:${issuer}:${id}; the agent memoizes nothing (mcp-auth.ts:79-85). Bounded by expiresAt, passed verbatim from expires_at_ms |
IdP-side grant revoked, TemperAs mode | 0, atomic | A row update in the same transaction — no network, no failure mode (slack_disconnect_service.rs:121-140) |
IdP-side grant revoked, ExternalIdp mode | best-effort | On failure it logs a structured warning and commits the local destruction anyway (slack_disconnect_service.rs:141-156) |
| IdP-side grant revoked, ciphertext unopenable after a key rotation | never attempted | An unopenable ciphertext has no value to revoke; returns Ok(None) and destroys anyway (slack_grant_vault_service.rs:348-402) |
Profile deactivation (is_active = false) | 0, enforced per request | The mint path checks it before decrypting anything (slack_grant_vault_service.rs:263-267), and require_auth refuses the token (middleware/auth.rs:90-93) |
require_auth is stateless JWKS validation: extract bearer, fetch the cached decoding key,decode() with algorithm-scoped validation, then resolve the profile through the shared auth seamcrates/temper-api/src/middleware/auth.rs:49-102). There is no revocation list, no jtimigrations/20260717000030_slack_grant_vault.sql:48-51):"Disconnected" means "cannot mint again". It does NOT mean "cannot act".
Disconnect is not an offboarding control. A token issued moments before the disconnect keeps
working until its ownexp. To actually cut someone off, deactivate the profile — that is
enforced per request, at latency 0, on every surface. The CLI says as much on stderr
(crates/temper-cli/src/commands/admin_slack.rs): "disconnect unbinds an identity, it does not
deactivate an account."
exp, set by the IdP when it minted the token.access_expires_at on the vault row is temper's cache bookkeeping, derived from theexpires_in, defaulting to DEFAULT_AT_TTL_SECS = 3600 when the IdP omits it andMAX_AT_TTL_SECS = 86_400 (slack_grant_vault_service.rs:22-50). The clamp is aexpires_in wrapping the cast negative — it doesAT_REFRESH_SKEW is a FLOOR, not a ceilingslack_grant_vault_service.rs:273):AT_REFRESH_SKEW = Duration::minutes(5) (:20). This means a cached token is handed back200 carrying a refusal is the honesthandlers/slack_mint.rs). The response is two arms:{ "status": "token", "access_token": "…", "expires_at_ms": 1784505600000 }
{ "status": "refused", "reason": "not_linked" }
{ "status": "refused", "reason": "not_vaulted" }
{ "status": "refused", "reason": "standing", "refusal": { "kind": "denied" } }not_vaulted is fixed by re-linking. A standing refusal is fixed by an adminrevoked for both, and the agent offered temper slack disconnect toreason is LinkRefusal (temper-core/src/types/slack.rs); under standing it carriestemper_principal::Refusal verbatim, so the ledger, the API and the agent cannot disagree aboutstatus, reason, kind — are distinct on purpose:NotVaulted is reachable for a user whom link-state calls linked — which is exactly why it istemper_principal::admit can produce are reachable here, pinned byonly_admit_reachable_refusals_ever_surface (slack_link_state.rs:173).mint_reports_not_vaulted_distinctly_from_not_linkedslack_link_test.rs:1761) and an_unapproved_principal_links_but_cannot_mint_until_approved:1526) — the latter asserting a born-Denied human is refused on standing, not told totests/identity.test.ts and tests/slack-dispatch.test.ts assert the@temper traffic.slack_link.rs:93-99).slack_link_service.rs:198-226).INTENT_TTL, slack_link.rs:23) and is single-use.The sting is in the tail. The no-rebind guard that closes the already-linked case makes a
wrong first binding unrecoverable by the victim — they cannot link, because the principal is
taken, and they cannot unbind it, because they do not own it. Recovery requirestemper admin slack disconnectby a system admin. The refusal page names this (it admits the
principal is linked, a deliberate bounded disclosure) but the other profile's handle is never
revealed (slack_link.rs:252-269).
internal_sig binds neither method nor path — key separation is the sole defence"{timestamp}.{body}" and nothing else (internal_sig.rs:38-46). It does not{"slack_principal_id": "..."}.audit-signature-secrets.sh computes distinctness rather than baselining it, and whyslack_mint_internal_routes is a third router rather than one more route onslack_link_internal_routes (routes.rs:278-295; layered at both merge sites — create_app:386-390 and create_internal_app :435-439).The two-merge-site hazard is itself defended, and the story is instructive. Because each
layer name appears twice inroutes.rs, deleting one of the two mounts left the name still
present elsewhere in the file — so a whole-filegrep -qstayed green through exactly that
edit, while one deployed surface served the route ungated.audit-route-auth.sh:152-158now
asserts the layer per builder (require_layer_in ... $APP_BUILDERS). Its own rationale is
blunt about the stakes: "Forslack_mintthat is not a downgrade to authenticated-but-broad, it
is act-as-any-user."
timestamp_is_fresh accepts ±30s in either direction (MAX_SKEW_SECS = 30,internal_sig.rs:36,66-68). There is no nonce cache and no single-use tracking of signatures, so aCOMMIT fails after Auth0 returns a rotated RT but before the UPDATE commits,slack_grant_vault_service.rs:216-223):No row lock can make an external HTTP effect atomic with a local commit.
SELECT ... FOR UPDATE OF v) does serialize concurrent mints of the same principal,The mitigation is an Auth0 refresh-token-rotation leeway setting, and nothing in this repo
verifies it is enabled. It is out-of-repo configuration, asserted by neither a test nor a CI
guard nor a startup check. An operator who skips slack-setup.md
§ Step 1.4 gets no warning — only
occasional users whose grant bricks. Recovery in that case is a re-link.
Debug — closed, and now guardedDebug, so anything formatting them with {:?}ApiConfig (crates/temper-services/src/config.rs) — internal_reconcile_secret,embed_dispatch_secret and slack_mint_secret, the keys behind all three signature gates.TokenResponse (crates/temper-auth/src/token.rs) — the plaintext refresh token and accessslack_grant_vault_service::mint_access_token.Debug, joining MintOutcome, NewGrant, SlackMintResponse,VaultKey and SlackLinkConfig. No leak was ever live — no call site formatted either type — soSlackLinkConfig's hand-written impl carries this rationale — a derived Debug "would print itApiConfig is formatted." The author reasoned aboutTokenResponse was missed for a different reason: it lives in a different cratetemper-auth), and the redaction convention had propagated only within the crates where it was.github/scripts/audit-credential-debug.sh, which fails CI on a credential-bearingDebug. Treat that guard, not the convention, as the thing keeping thisSLACK_VAULT_ENC_KEY makes every stored grant unreadable; affected userskey_version column for a future keyring, stamped 1 and not yetmigrations/20260717000030_slack_grant_vault.sql:23) — do not treat rotation as seamless.| Property | Defended by | Kind |
|---|---|---|
| Each signature gate reads a distinct secret | .github/scripts/audit-signature-secrets.sh (computed, not baselined) | CI guard |
| Every route's auth posture; no silently-dropped layer | .github/scripts/audit-route-auth.sh | CI guard |
No new credential type silently derives Debug | .github/scripts/audit-credential-debug.sh | CI guard (baselined) |
| Grant-sink chokepoint | .github/scripts/audit-grant-sinks.sh | CI guard |
| The guards themselves fail when the thing they protect breaks | test-audit-*.sh × 4, in the guard-tests job | meta-guard |
| Layer present in each app builder, not just somewhere in the file | audit-route-auth.sh:152-158 + test-audit-route-auth.sh:88,96 | CI guard |
| The mint route rejects the link key (bidirectionally) / forged / unsigned calls | slack_link_test.rs:1506, :1544 (drive the route) | e2e |
| The link-state route rejects a forged signature | slack_link_test.rs:799 | e2e |
| An already-linked user is never issued a stealable URL | slack_link_test.rs:731 | e2e |
| A link with no refresh token writes nothing and reports failure | slack_link_test.rs:1587 | e2e |
| A transplanted ciphertext will not open (AEAD associated data) | slack_grant_vault_service.rs:723 | unit |
| A deactivated profile mints nothing | slack_grant_vault_service.rs:687 | unit |
| Minting disabled without its secret; linking unaffected | slack_link_test.rs:1748 | e2e |
not_vaulted vs not_linked stay distinct | slack_link_test.rs:1761 | e2e |
| A refused human is told the remedy that works (standing ⇒ admin, never re-link) | slack_link_test.rs:1526 (server) · tests/identity.test.ts "offers re-link ONLY where re-linking is the actual remedy" (agent) | e2e + unit |
Only admit-reachable refusals surface on the mint | slack_link_state.rs:173 | unit |
| Link is lookup-only (no profile creation) | slack_link_test.rs:455 | e2e |
| State nonce is single-use | slack_link_test.rs:505 | e2e |
| No rebind to a different profile | slack_link_test.rs:622 | e2e |
| Disconnect unbinds; next mention re-prompts | slack_link_test.rs:954, :1316 | e2e |
| Admin disconnect refuses a non-admin | slack_link_test.rs:1116 | e2e |
| Rust/TS HMAC constructions cannot drift | shared known-answer vector: internal_sig.rs tests + tests/oauth/wire-contract.test.ts | contract test |
| Agent never signs a mint with the link key | mention/tests/mint.test.ts:42-56 | unit |
getToken fails closed on every non-token status | mention/tests/mcp-auth.test.ts:73-89 | unit |
| No token reaches any log sink | mention/tests/mcp-auth.test.ts:97-134 | unit |
| Tool allow-list stays read-only | mention/tests/mcp-auth.test.ts:143-196 (exact list + mutating-name-family scan) | unit |
| All four principal shapes accepted, none parsed | slack_link.rs:527-536; mention/tests/identity.test.ts:44-121 | unit |
| No new public eve event sink after an upgrade | mention/tests/events.test.ts (derives from eve's real defaultEvents at runtime) | unit |
@vercel/connect, documented inmention/CLAUDE.md, not asserted).handlers/slack_mint.rs and services/slack_mint_service.rs contain no mod tests at all.slack_mint_service.rs:39-41, a test that calls mint_for_mention directlyinternal/development/security-audit-playbook.md — how topackages/agent-workflows/mention/CLAUDE.md —