Skip to content

CAIRN delivery authenticates with the service trust path, not client_credentials

Date: 2026-08-11 Status: open Supersedes: none Superseded-by: none — current

Stone delivery to Pebble authenticates with an airlock client_credentials token. That path cannot work against the current provider, and the reason is structural rather than configurational:

Pebble introspects a presented token using its own client credentials (pebble/src/pebble/api/middleware/auth.py:252-258). better-auth’s OAuth provider only permits a client to introspect tokens it minted itself — validateOpaqueAccessToken short-circuits on if (clientId && accessToken.clientId !== clientId) return { active: false }, and the introspecting client’s id is what reaches that comparison. A token minted for petrova-cairnet-bridge therefore reads active: false to Pebble, which is indistinguishable from a forged one. The result is HTTP 401 Authentication required, confirmed live by petrova cairnet-preflight.

No credential work on the petrova side reaches this. Re-seeding, re-scoping and rotation all produce a valid token that Pebble still cannot validate.

Two further faults exist behind it and are not fixed by this decision: Pebble’s introspection URL omits better-auth’s /api/auth basePath, and its introspection client id/secret are unset in the deployed environment. Both are real; neither is sufficient.

53 stones have been undeliverable since roughly 2026-06, and 57 were destroyed on 2026-08-11 by a classifier that read the resulting refusal as permanent.

Stone delivery authenticates with Pebble’s CAIRNET service trust path.

Pebble already implements it (middleware/auth.py:165-215): a caller presenting the shared secret as X-API-Key on a /api/cairn/* path, plus an identity in X-Cairn-User-Id and X-Cairn-User-Email, is accepted as that principal and granted cairn:read + cairn:emit. It is live, tested code that needs no change in devarno-cloud beyond setting PEBBLE_CAIRN__SERVICE_KEY.

Emitter-side configuration:

VariableMeaning
PETROVA_PEBBLE_SERVICE_KEYthe shared secret; matches PEBBLE_CAIRN__SERVICE_KEY
PETROVA_CAIRN_USER_IDprincipal Pebble attributes the write to
PETROVA_CAIRN_USER_EMAILrequired alongside it; Pebble 400s without both

Resolution is centralised in cli/src/cairnet/auth.ts. A configured service key wins over a bearer token, because configuring one is a deliberate act and the bearer path cannot currently succeed. With no service key, behaviour is exactly as before.

Identity becomes self-asserted. Under client_credentials, airlock vouches for who the caller is. Under the service path, whoever holds the key may claim to be anyone — the identity headers are trusted on the strength of the secret alone. Pebble’s own source acknowledges this (“bad role can only come from a compromised cairnet, in which case the service key is already game-over”).

Three consequences worth naming rather than discovering:

  1. The key’s blast radius is wider than cairnet’s was. It was designed for one server-side proxy. It now also lives wherever the emitter runs — operator machines and CI. A leaked key forges stones under any identity.
  2. admin is never claimed. Pebble lifts X-Cairn-User-Role: admin to cairn:admin (mint and rotate agent keys). The emitter omits the header entirely and takes the viewer default, which still grants read + emit. This is enforced by a test, not by convention.
  3. It covers /api/cairn/* only. The trust path is path-scoped in Pebble, so the LORE surface under /api/knowledge/* — the ROCKY probe’s first check — still authenticates as a bearer and still fails. ROCKY will keep reporting unreachable for LORE until the introspection fault is fixed properly.

Audience-validated JWTs. The correct destination, and further away than it looks. pebble/src/pebble/core/config.py already carries airlock_audience: "pebble" and airlock_jwks_url, but nothing reads either: the Bearer branch is entered only when the token is not JWT-shaped (middleware/auth.py:252), so a JWT is silently dropped. Choosing it means writing JWKS verification in Pebble, adding a Pebble audience to airlock’s validAudiences, and changing this repo’s emitter, which deliberately omits RFC 8707 resource so airlock returns an opaque token. Three repos under time pressure to unblock a queue — the same pressure that produced the classifier bug that deleted 57 stones.

Resource-server introspection in airlock. Most architecturally correct, most invasive, and the piece most likely to break on a better-auth upgrade.

Both remain open as follow-ups. This decision buys delivery, not closure.

  • Delivery works once the operator sets PEBBLE_CAIRN__SERVICE_KEY in Pebble and the three emitter variables. petrova cairnet-preflight goes token_rejectedok and reports which mode authenticated.
  • The introspection fault survives, unfixed, and still blocks LORE. It must not be considered closed because stones flow again.
  • The shared secret needs a rotation story it does not currently have.
  • Human operator has reviewed the trust trade in “What this trades away” and accepts a self-asserted identity on the delivery path.

Unchecked. Per docs/decisions/2026-08-10-countersign-gates-closed-status.md this decision stays open until a human ticks it; an agent never does.