Audit trail¶
Every placement decision and every issued credential is recorded. What a record holds, where it goes, and how to answer a security review's questions from it.
Where records go¶
Structured JSON on the hub's stdout, alongside the rest of its output. There is no bespoke sink, no database and no retention policy of cellcast's own: records land in whatever log pipeline the adopter already runs, and the retention, access control and immutability decisions stay where they already are.
Every record carries "msg":"audit". That is the field to select on, in preference to matching on
prose that is free to change.
The trail is not levelled by --log-level. A hub started with --log-level=error still emits
every record: a verbosity flag must not be able to silence it.
The two records¶
One request produces up to two records, tied together by request_id, which is also the
X-Request-Id the caller was handed and the id on the hub's ordinary request log line.
event |
Written when | outcome |
|---|---|---|
placement |
the hub decided which cell the caller may reach, or refused to | granted, dry-run, refused |
mint |
the hub issued a credential for that cell, or failed to | granted, refused |
They are separate because either can happen without the other. A dry run places and never mints. A mint can fail against a cell that placement legitimately chose, and that is a fault in the cell rather than in the request.
A refusal is recorded with the same fields as a success, including the candidate table showing every registered cell and which filter stage rejected it. A trail holding only what worked answers half the questions asked of it.
What a record holds¶
{
"time": "2026-09-08T09:14:22.181Z",
"level": "INFO",
"msg": "audit",
"event": "mint",
"outcome": "granted",
"request_id": "8f2c1d4ab90e5577",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:acme/checkout:ref:refs/heads/main",
"claims": { "repository": "acme/checkout", "ref": "refs/heads/main" },
"workload": "checkout-api",
"requested_ttl": "20m0s",
"cell": "prod-eu-1",
"policy": "acme-prod",
"strategy": "LeastLoaded",
"confidence": "high",
"namespace": "apps",
"service_account": "deployer",
"granted_ttl": "15m0s",
"expires_at": "2026-09-08T09:29:22Z",
"token_sha256": "sha256:1d1cb1a1...c0ffee"
}
namespace and service_account are the blast radius of the token this record describes: they are
what the credential can act as, and the only reason the record is worth keeping after the credential
has expired.
confidence says how much of the permitted fleet the hub could see when it decided. degraded
means at least one permitted cell was excluded because its capacity was stale, so the chosen cell is
the best of what the hub could see, which is not the same claim as the best there is.
Token material¶
No record holds a token, or any prefix of one. token_sha256 is a SHA-256 digest of the whole
token, which is enough to match a token found in a build log against the record that issued it and
is not enough to use one.
To identify the record that issued a leaked token:
printf %s "$LEAKED_TOKEN" | shasum -a 256 | awk '{print "sha256:" $1}'
# then find it
kubectl -n cellcast-system logs deploy/cellcast-hub \
| jq -c --arg h "sha256:..." 'select(.token_sha256 == $h)'
A prefix of the token would correlate just as well and is forbidden. A JWT's leading bytes are its header and the segment boundaries move, so "just a prefix" is not a fixed amount of the secret. See the threat model, T-05.
internal/hub/audit enforces this structurally rather than by convention. The record type has no
field that can carry credential material, and a test classifies every field, failing on any new one
until it has been classified.
Worked example: which pipeline deployed to prod-eu-1 last Tuesday¶
Everything below comes out of the log alone. No cluster access, no cellcast API call.
Assume the pipeline collected the hub's stdout into hub.log, one JSON object per line.
Every credential issued for that cell, that day:
jq -c 'select(.msg == "audit" and .event == "mint" and .outcome == "granted"
and .cell == "prod-eu-1"
and (.time | startswith("2026-09-01")))' hub.log
Reduced to who, what and when:
jq -r 'select(.msg == "audit" and .event == "mint" and .outcome == "granted"
and .cell == "prod-eu-1"
and (.time | startswith("2026-09-01")))
| [.time, .claims.repository // .subject, .workload, .service_account, .granted_ttl]
| @tsv' hub.log
2026-09-01T08:41:03.774Z acme/checkout checkout-api deployer 15m0s
2026-09-01T11:02:55.118Z acme/checkout checkout-api deployer 15m0s
2026-09-01T16:20:41.902Z acme/billing ledger-api deployer 10m0s
That is the answer: two repositories deployed to prod-eu-1 on the 1st, three times between them,
each holding a credential scoped to the deployer service account in apps for fifteen minutes or
less.
What was refused:
jq -r 'select(.msg == "audit" and .outcome == "refused")
| [.time, .subject, .workload, .event, .reason] | @tsv' hub.log
2026-09-01T09:15:12.006Z repo:acme/experiments:ref:refs/heads/spike spike-api placement NoPolicy
2026-09-01T14:47:30.551Z repo:acme/billing:ref:refs/heads/main ledger-api placement NoEligibleCells
The first is an authorization refusal: no PlacementPolicy matched that caller, so the hub denied by
default. The second is a fleet condition: the caller was permitted and every cell it may reach was
draining or stale. The candidate table on that record names each one and the stage that refused it.
One request end to end, when a pipeline reports a failure and quotes its X-Request-Id:
That returns the placement record, the mint record and the ordinary request log line, in order.
The kubectl describe view¶
The same events are also written as Kubernetes Events on the Cluster they concern, so an operator
already looking at a cell can see who has been deploying there without leaving the terminal.
$ kubectl -n cellcast-system describe cluster prod-eu-1
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Placed 12m cellcast-hub placed checkout-api for repo:acme/checkout:ref:refs/heads/main under policy acme-prod
Normal CredentialIssued 12m cellcast-hub issued a 15m0s credential to repo:acme/checkout:ref:refs/heads/main, scoped to apps/deployer
Warning MintFailed 4m cellcast-hub could not mint a credential for repo:acme/billing:ref:refs/heads/main: MintFailed
This view is lossy and the JSON trail is the authoritative one. Kubernetes Events are aggregated, spam-filtered and expire on the cluster's own schedule, so a busy hub will have some of these collapsed or dropped. Making them complete would mean an API server write on every deploy in the estate, in the critical path. Use them to orient; answer the question from the log.
Dry runs produce no Event. A refused placement produces none either, because it never reached a cell to hang one on; it is in the JSON trail like everything else.