CAIRN delivery authenticates with the service trust path, not client_credentials
Date: 2026-08-11 Status: open Supersedes: none Superseded-by: none — current
Context
Section titled “Context”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.
Decision
Section titled “Decision”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:
| Variable | Meaning |
|---|---|
PETROVA_PEBBLE_SERVICE_KEY | the shared secret; matches PEBBLE_CAIRN__SERVICE_KEY |
PETROVA_CAIRN_USER_ID | principal Pebble attributes the write to |
PETROVA_CAIRN_USER_EMAIL | required 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.
What this trades away, stated plainly
Section titled “What this trades away, stated plainly”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:
- 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.
adminis never claimed. Pebble liftsX-Cairn-User-Role: admintocairn:admin(mint and rotate agent keys). The emitter omits the header entirely and takes theviewerdefault, which still grants read + emit. This is enforced by a test, not by convention.- 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 reportingunreachablefor LORE until the introspection fault is fixed properly.
Alternatives rejected
Section titled “Alternatives rejected”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.
Consequences
Section titled “Consequences”- Delivery works once the operator sets
PEBBLE_CAIRN__SERVICE_KEYin Pebble and the three emitter variables.petrova cairnet-preflightgoestoken_rejected→okand 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.
Countersign
Section titled “Countersign”- 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.