Skip to main content

Secret-custody rotation — a daemon renews OAuth tokens it can never read

Primitives used: axonstore · rotate · tool · daemon

// The connected-account shape: each tenant's CRM OAuth bundle lives in
// the platform's ENCRYPTED secret custody under keys `crm.<provider>`
// (seeded by the adopter's OAuth callback via POST /tenant/secrets).
// The program below OWNS the token lifecycle without ever seeing one.

// A read-only METADATA view over the tenant's custody, scoped to the
// `crm.*` class. Schema is synthesized law: key, version, created_at,
// expires_at — the VALUE has no column (writes are axon-T897).
axonstore CrmTokens {
backend: secrets
class: crm
}

// The renewal exchange, executed by the adopter's tool-server: it
// receives the CURRENT bundle under the reserved `axon_rotation`
// request field, calls the vendor's token endpoint, and answers
// `axon_rotated: { value, expires_at }`. Endpoint wiring is config,
// never code (v2.8.0 `tool.base_url`).
tool RefreshCrmToken {
parameters: { provider: String }
output_type: String
}

// A vendor ACTION tool: `secret: crm.hubspot` injects the per-tenant
// credential into its request under the reserved `axon_secret` field
// at dispatch — the flow never touches it (axon-T902 keeps the key a
// config key; a credential literal in source is unrepresentable).
tool CrmCrearContacto {
secret: crm.hubspot
parameters: { nombre: String, email: String }
output_type: String
}

flow RotateExpiring() -> Unit {
// Observability first: which connections near expiry? Metadata
// rows only — {key, version, created_at, expires_at}.
retrieve CrmTokens { where: "expires_at < now() + interval '10 minutes'" as: expiring }
// The mediated sweep: one exchange per matching key, CAS commit at
// version+1 (two daemon replicas cannot double-spend a refresh
// token). `result` binds the metadata-only summary
// {attempted, rotated, failed} — never a value.
rotate CrmTokens where "expires_at < now() + interval '10 minutes'" with RefreshCrmToken as result
step Report {
ask: "Summarize this rotation sweep for the ops log: ${result}."
}
}

// The clock: a cron daemon drives the sweep. The flow decides WHEN
// (cognition); the runtime performs every custody-touching act
// (dispatch) — `dispatch_vs_cognition`, applied to borrowed authority.
daemon TokenKeeper {
requires: [secret.rotate]
listen "cron:*/5 * * * *" as tick {
run RotateExpiring()
}
}

// Consuming the credential is just using the tool — the injection is
// invisible to the flow.
flow CrearContacto(nombre: String, email: String) -> String {
use CrmCrearContacto(nombre = nombre, email = email)
return CrmCrearContacto.output
}