CI credentials and expiry
Constellation's CI workflows authenticate to Project Tracker and the wiki with Directory API keys stored as GitHub Actions secrets. Keys issued to an admin role default to a 14-day lifetime and are capped at 30 days by default — API_KEY_TTL_MAX_ADMIN_DAYS can raise that cap, up to the platform's absolute ceiling of 365. Non-admin keys are capped at 365. The 30-day cap is a configurable default, not a law, and it binds only a personal admin key: since INF-681 (2026-10-29), both CI credentials here are SERVICE-principal keys with a fixed 90-day lifetime (Service principals).
This page is the operator's reference for what breaks when one expires, how you are told, and how to rotate.
Why this page exists
On 2026-07-26 20:47 UTC the key behind WIKI_SERVICE_TOKEN reached its expiry. The next Spec → Wiki Sync run failed, and so did every run for the next five days. Nothing had noticed, for two reasons:
- The sync reported only
401 UNAUTHORIZED— while holding a token whose ownexpclaim said, in plain text, that it had died two hours earlier. - Nothing read that claim ahead of time. Expiry is a function of the calendar, not of a diff, so no PR gate can catch it.
Because that sync is the GitHub → wiki edge of the spec → wiki → knowledge-base funnel, the effect was not cosmetic: no spec mirror advanced to approved, so nothing was nominated for the KB, so the next release's curation pass found an empty ingestion queue. Tracked as INF-357.
The watched credentials
| Secret | What stops working when it expires |
|---|---|
WIKI_SERVICE_TOKEN | the spec → wiki sync (PLT-302), and with it the KB ingestion-candidate funnel (PLT-269) |
PT_AUTH_TOKEN | the multi-LLM PR reviewer and every CI step that reads Project Tracker |
The list is defined in WATCHED_TOKENS in scripts/check-ci-token-expiry.ts. Adding a new CI credential means adding a row — a secret missing from that list is simply unwatched, which is the exact failure mode above.
SONAR_ANALYSIS_TOKEN, the SonarCloud analysis token, is a known unwatched credential: it is not a JWT, so this check cannot read its expiry. Its expiry date is recorded on that page.
Service principals — the per-integration credential model (INF-372)
Since INF-372, CI credentials are meant to be owned by per-integration SERVICE principals, not by a human admin's account. The model:
- Each integration has one
identity.usersrow withprincipal_kind = 'SERVICE'and an immutableintegration_key(spec-wiki-sync,multi-llm-review). The email (ci+<key>@constellation.local) is display-only; authorization keys on theintegration_key, which no request path can write. - Each principal holds exactly one GLOBAL tenant role whose grants must exactly equal its registered profile in
apps/directory/src/lib/service-principals.ts— one code registry both provisions the grants and validates them at mint time, so authority drift locks issuance instead of shipping silently. - Keys are minted on behalf of the principal by an admin holding the
api_key.issue_for_servicepermission (resolved from the DB permission chain — aPLATFORM_ADMINrole name alone does not qualify). The issued token carries the principal's identity and roles, so every automated action becomes attributable to its integration, and the TTL is a fixed 90 days — the request accepts no expiry input. - A
vercelprofile deliberately does not exist yet: its consumer's permission needs have not been identified in source (tracked on INF-372).
integration_key | Backs secret | Authority | Rotation cadence | Owner |
|---|---|---|---|---|
spec-wiki-sync | WIKI_SERVICE_TOKEN | role wiki:page:write (name-as-grant, wiki interim) | 90 days (fixed at mint) | the CI_CREDENTIAL_OWNER repository variable |
multi-llm-review | PT_AUTH_TOKEN | tasks.read + projects.read | 90 days (fixed at mint) | the CI_CREDENTIAL_OWNER repository variable |
Provisioning (one-time per environment, operator-run)
The principals, their roles, grants, and org-bound memberships are tenant-bound and therefore cannot be seeded by a migration (identity.roles.tenant_id is NOT NULL, and there is no portable SQL identifier for the platform tenant). Provision them once per environment with the operator script — idempotent, one audited transaction per profile, and it re-validates every profile through the real issuance-path check before committing:
DIRECT_URL=postgresql://… npm run db:provision-service-principals -w @constellation/directory -- --tenant <tenant-uuid> --org <organisation-uuid>
It also reconciles the default roles (so canonical Admin picks up api_key.issue_for_service) and repairs any deploy-window agent+… rows. A second run is a verified no-op.
Issuing a key on behalf of a principal
A browser-session admin holding api_key.issue_for_service POSTs the target instead of issuing for themselves. First find the principal's user id. It is stable once provisioned, so a rotation can reuse the last one; the queries the provisioning script prints do not select it. Run this as a role that bypasses row-level security, such as postgres (the role provisioning needs): identity.users is row-level secured, so any other role sees no rows:
SELECT id, integration_key FROM identity.users WHERE tenant_id = '<tenant-uuid>' AND principal_kind = 'SERVICE';
Then mint from the browser devtools console, on that environment's Directory root origin (production: constellation.planetb2b.com), signed in as that admin. The request is same-origin: your session cookie authenticates it, the browser's Origin header passes the route's origin check, and no session token leaves the browser. Let the page finish loading first. / redirects to /projects/dashboard, which is a full page load, and a page load clears everything the console holds. Run both snippets below on that one page, with no navigation between them. Chrome may ask you to type allow pasting first.
fetch('/api/auth/api-keys/service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: 'GitHub WIKI_SERVICE_TOKEN (spec-wiki-sync) YYYY-MM-DD',
targetUserId: '<service-user-uuid>',
}),
})
.then(async (response) => {
const result = await response.json();
if (response.ok) {
window.__key = result.data.token;
console.log(
response.status,
'ready; id',
result.data.id,
'expires',
result.data.expiresAt,
result.data.roles,
);
} else {
console.log(response.status, JSON.stringify(result));
}
})
.catch((error) => console.log('request failed:', String(error)));
It prints the new key's id, expiry and roles, never the token. Note the id. If it prints request failed, list the principal's keys before you retry: the key may have been minted anyway. Once it has printed the id, copy the token to the clipboard as a separate paste:
copy(window.__key);
delete window.__key;
copy() is a DevTools console command, defined only while the code you entered at the prompt is running. Called inside the .then() callback above, which runs later, it would throw copy is not defined after the key was minted, and the token would be lost. The clipboard goes to gh secret set in a Terminal (Rotating a key step 2); a Terminal command pasted into the console fails with missing ) after argument list. This page gives no Terminal form of these calls, because one would need your session token in the shell.
Note the /service suffix: this is a separate endpoint from self-issuance, and it is strict. Posting the same body to /api/auth/api-keys is refused rather than quietly issuing a key for you — which is the failure this split exists to remove, since such a key carries your admin identity and would then sit in CI. Any unrecognised field is likewise a 400 naming it.
The response carries the token exactly once. Do not pass expiresInDays — the request rejects it (the 90-day TTL is fixed, and an operator-lowered API_KEY_TTL_MAX_USER_DAYS below 90 vetoes issuance loudly rather than silently shortening). Minting keys and setting secrets remain human actions.
If a mint goes wrong, the key it created stays live for 90 days. That covers a key whose token was lost (a failed copy() or request, or a page load before the copy ran) and a spare key from a retry. List the principal's keys (Listing and revoking a principal's keys), and revoke the keys this attempt created, apart from the one you stored; createdAt tells them apart. The key you are replacing is not one of them: it stays live as your fallback until Rotating a key step 5. The stored key is the id you noted. Failing that, re-run its consumer (step 3): among this attempt's keys, the stored one's lastUsedAt fills in, and the others stay null. The CI Credential Expiry re-run prints the jti each secret holds, which names that key's identity.api_keys row; the list carries no jti, so matching on it needs the database.
Listing and revoking a principal's keys
Both surfaces require the same api_key.issue_for_service permission; human-owned keys stay strictly owner-revocable and are refused here. Run them in the browser console, on the same origin as the mint. This one prints the principal's keys as a table, with each key's id, label, expiry, createdAt, lastUsedAt and revokedAt:
fetch('/api/auth/api-keys?serviceUserId=<service-user-uuid>').then(async (response) => {
const result = await response.json();
if (response.ok) {
console.table(result.data);
} else {
console.log(response.status, JSON.stringify(result));
}
});
This one revokes a single key, and prints 204 revoked when it has:
fetch('/api/auth/api-keys/<key-id>?serviceOwned=true', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'rotation' }),
}).then(async (response) =>
console.log(response.status, response.status === 204 ? 'revoked' : await response.text()),
);
Revocation takes effect at every zone within the principal check's success-cache window — 30 s by default, and a configured TTL is clamped platform-side at 60 s (PRINCIPAL_CHECK_CACHE_TTL_MAX_MS), so no deployment can stretch it (the wiki included, since the INF-372 wiki half). Directory's profile-role name-release guard sizes its revocation grace from that ceiling. Both issuance and privileged revocation write auditCritical entries naming the acting admin and the subject principal — by stable identifiers, never email.
Grant changes do not shrink keys already in the wild. Two layers move independently here, and both statements in this page are true of their own layer: the token freezes the role names at signing (the snapshot Reading a failure describes — no Directory-side role re-assignment reaches an issued key), while what those names grant is resolved live by each consumer per request. So editing a service role's permission set changes what every outstanding key can do, immediately — and there is deliberately no revocation-on-role-change (ruled 2026-08-01 — the 90-day TTL is the bound, and grant drift locks the next mint rather than the outstanding key). When deliberately changing a service role's grants, do it in the safe order (Codex r30): revoke the integration's outstanding keys first (procedure above), wait out the principal-check cache window (≤ 60 s), then change the grants, then issue and store the replacement key — editing first hands every still-live (or compromised) key the new authority for the whole operator window.
Migration note: both watched secrets have held SERVICE keys since 2026-10-29 (INF-681). A personal admin key found in either one is the legacy model: replace it with the on-behalf-of issuance in Rotating a key step 1, where the personal-key UI path is labelled legacy.
How you are warned
Two mechanisms, deliberately different in strength:
CI Credential Expiry— a scheduled workflow (Mondays 07:00 UTC) that fails when any watched token is expired, is inside the warning floor, or is unreadable, and opens a tracking issue labelledci-credential-expirycarrying the report and the run link. Set theCI_CREDENTIAL_OWNERrepository variable to a GitHub login (or anorg/teamslug) so the issue is assigned and@-mentioned to a person — GitHub has no way to watch a label, and an unassigned bot-created issue notifies nobody at the default "Participating and @mentions" level. The scheduled workflow fails until that variable is set — deliberately, and on every run including passing ones, so the misconfiguration surfaces on a quiet Monday rather than in the middle of the first outage. The alert issue is still created regardless — fail-closed on configuration, never on delivery. It is not auto-closed while the recipient is unusable, and that asymmetry is deliberate: an alert whose subject is "nobody would be told" must not close itself before anyone is reachable to read it. Fix the recipient and the next passing run closes it.
gh variable set CI_CREDENTIAL_OWNER --repo B2B-Online/constellation --body "<github-login>"
A user login is positively confirmed before it is accepted: the workflow calls GET /repos/{owner}/{repo}/assignees/{login}, which answers exactly "can this user be assigned here" and needs only the issues permission the job already holds. A pass proves access, because GitHub refuses to assign a user without it. Anything else — nonexistent account, no repository access, a rate limit, any API error — fails the job, because access was never established.
A team is not accepted by default. It cannot be assigned, and its existence cannot be confirmed with a repository-scoped token (GET /orgs/{org}/teams/{slug} needs organisation Members-read). An unverifiable recipient is indistinguishable from a mistyped one, so it fails closed. If you want a team anyway, check the slug by hand and set the acknowledgement explicitly:
gh variable set CI_CREDENTIAL_OWNER_TEAM_UNVERIFIED_OK --repo B2B-Online/constellation --body "<org>/<team>"
Set it to the team slug itself, not true. The acknowledgement is bound to the slug it acknowledges, so changing CI_CREDENTIAL_OWNER later re-opens the question instead of inheriting a stale yes for a team nobody checked. It is a logged decision rather than a silent default, and every accepted run warns that the slug is unverified.
It comments on the existing issue rather than opening a new one each week, and closes it automatically once the check passes — so an open issue always means "act now". Alerts are found by their label, and also by a hidden marker in the body, so an alert opened while the label was momentarily uncreatable is still updated and still auto-closed (it is re-labelled as soon as label access recovers). Do not delete that marker comment from an issue — an alert without it can only be closed by hand. The marker is not a credential and grants nothing: every lookup additionally requires the issue to have been opened by this workflow's own bot, so pasting the marker into an unrelated issue does not hand it to the automation. The issue exists because a red scheduled run is not a delivered signal: that is precisely what went unnoticed for five days.
- A preflight on each consuming workflow — warn-only, at a 14-day floor, so the notice appears in a workflow people already read rather than only in one they might not. Both are
continue-on-errorand can never stop the real work from running:Spec → Wiki SyncchecksWIKI_SERVICE_TOKEN.Multi-LLM reviewchecksPT_AUTH_TOKEN. This one matters more than it looks: an expired PT token does not fail the review — the reviewer degrades to flat-file grounding and posts anyway, losing its PT-ticket and KB grounding while still going green. Nothing else would tell you.
The token's principal must be an ACTIVE member of the organisation the workflow names (INF-738). The reviewer sends x-act-as-org from the literal PT_ACT_AS_ORG in .github/workflows/multi-llm-review.yml, so every PT-ticket and KB read acts in that organisation. A key whose principal lacks that membership is refused with a 403, logged as ::notice::KB grounding unavailable (HTTP 403). The secret must also be an API-key token: PT ignores the selector for a session token or cookie, so the reviewer refuses to read with one and logs … grounding skipped (the PT credential is not an API-key token …). When you mint a replacement, check its kind and that membership as well as the expiry.
The warning floor (14 days) is deliberately longer than the weekly schedule, and shorter than the 30-day key lifetime you should be issuing — those two constraints are what fix it at 14, and a key minted for 14 days satisfies neither. With a 7-day floor and a weekly cron, a token expiring just after next Monday's run sits outside the floor today, so this Monday's run does not flag it — and the next one catches it minutes before it dies. If the cron is ever slowed, the floor must go up with it.
Scheduled workflows are evaluated from the repository's default branch, which here is develop — so the monitor activates on merge to develop and does not wait for a release to main.
It also runs on a merge to develop that touches its own workflow file. That is the bootstrap: a schedule-only workflow does not exist until its first tick, so a monitor merged after a Monday 07:00 UTC cron would sit inert until the following Monday with no earlier run to re-run — which for INF-357 would have been six days past the expiry it was built for.
There is deliberately no manual trigger. (The bootstrap above is not one: a push runs develop's own post-review workflow and code, the same trust level as schedule, with no ref for a caller to choose.) A workflow_dispatch lets the operator choose any ref, and this job hands both production secrets to that ref's copy of the script — so an unreviewed branch could exfiltrate them, and an in-workflow ref guard cannot prevent it because the chosen branch supplies the workflow file too. To check on demand, re-run the most recent scheduled run instead; a re-run replays the original event and ref:
gh run list --workflow ci-token-expiry.yml --limit 1
gh run rerun <id>
You can also run it locally against secrets already in your environment (the default 14-day floor is the right one — 30 days is the DEFAULT cap on an admin-issued key, not a ceiling: API_KEY_TTL_MAX_ADMIN_DAYS can raise it, clamped to the platform's absolute 365, and a non-admin key defaults to 365 already. A token cannot reveal the configured value, so the check names the default rather than claiming no longer key exists):
npm run check:ci-token-expiry
Rotating a key
Rotation is a human action — agents never source credentials.
-
Read which secret the warning names.
WIKI_SERVICE_TOKENandPT_AUTH_TOKENare separate credentials with separate consumers; every step below depends on which one you are rotating. -
Issue the replacement on behalf of the matching service principal — this is the canonical path for both watched secrets since INF-372: the key carries the SERVICE principal's identity (attributable, least-privilege) at the fixed 90-day TTL, with no expiry to choose and no way to reproduce the 14-day-default trap. As a browser-session admin holding
api_key.issue_for_service, follow Issuing a key on behalf of a principal above —POST /api/auth/api-keys/service(note the suffix; the plain endpoint self-issues and refusestargetUserId) with the principal'stargetUserId(the service user's UUID — the schema rejects anything else; find it with the query under that heading, looking up integrationspec-wiki-syncforWIKI_SERVICE_TOKENormulti-llm-reviewforPT_AUTH_TOKEN) and a label that says where it is used. If the principals are not yet provisioned in this environment, run the provisioning script first (Service principals).Legacy path — personal keys only. Self-issuing from the API keys UI mints a key bound to your account and roles. For the two watched secrets that is the pre-INF-372 model this rotation exists to retire — a non-attributable human credential, admin-capped at 30 days (and defaulting to 14, exactly the warning floor, when the expiry is left blank). Use it only for a personal key, never for
WIKI_SERVICE_TOKEN/PT_AUTH_TOKEN. -
Update the GitHub Actions secret named in the warning — there are two watched credentials and they are not interchangeable. Use the prompting form in a Terminal and paste the token at its prompt, so the value stays out of your shell history:
Terminalgh secret set WIKI_SERVICE_TOKEN --repo B2B-Online/constellationTerminalgh secret set PT_AUTH_TOKEN --repo B2B-Online/constellationThen clear the clipboard, which still holds the token. Copying any other text does it on every platform; on macOS this empties it:
Terminal (macOS)pbcopy < /dev/null -
Re-run the workflow that consumes that secret and confirm it goes green:
Rotated secret Verify with WIKI_SERVICE_TOKENSpec → Wiki Sync. It only triggers on a push touching.ai/specs/**, so use the Actions Re-run button on the most recent failed run rather than waiting for the next merge.PT_AUTH_TOKENMulti-LLM reviewon any open PR. Note it does not go red on an expired PT token — the reviewer degrades to flat-file grounding and still posts. Confirm from the run log that PT-ticket and KB grounding ran, not just that it is green: theRun reviewerstep must printKB grounding delivered(asKB grounding delivered: N citation(s)., or as a::notice::with 0 citations) or::notice::KB grounding dropped(the KB answered, but its answer did not fit the grounding token budget), and no::notice::saying PT-ticket or KB grounding was skipped, empty or unavailable (INF-738). -
If
WIKI_SERVICE_TOKENwas the one that expired and the sync was down for a while, back-sync the mirrors that were missed — the sync only publishes specs changed by the triggering push, so specs changed during the outage never get picked up by the next merge on their own. -
Revoke the superseded key — after step 3's verification, not before. A rotation ahead of expiry otherwise leaves the old key accepted for its remaining TTL, a live duplicate credential nobody is watching. List the principal's keys and revoke the one the new secret replaced (Listing and revoking a principal's keys); doing it only after the consuming workflow has gone green preserves rollback safety — until then the old key is your fallback. If the rotation happened because the key expired, there is nothing live to revoke and this step is a no-op.
Reading a failure
A 401 or 403 from the wiki now diagnoses itself, naming whichever status actually came back. The error names whether the token is expired and by how long, its jti (which identifies the identity.api_keys row to replace), and the secret to update:
GET .../api/pages/by-path/default/specs → 401 {"error":{"code":"UNAUTHORIZED",...}}
↳ WIKI_SERVICE_TOKEN EXPIRED at 2026-07-26T20:47:14.000Z (3 day(s) ago), jti ef459a75-… —
that is almost certainly this 401. Rotate it: issue a fresh Directory API key, then update
the WIKI_SERVICE_TOKEN GitHub Actions secret.
A 403 the wiki produced is not a credential failure and the diagnosis says so: every bearer problem — missing header, malformed token, expiry — returns 401 from withAuth, so a 403 from the wiki is only reachable after authentication has already succeeded. That guarantee is about the wiki's 403, and the diagnosis checks: a 403 whose body carries none of the platform's FORBIDDEN envelope may have come from Deployment Protection, a WAF, a CDN or a proxy, which answer before any bearer is examined — there it establishes nothing about the credential, and any token defect is reported as a second problem. Two things produce one, in the order they are evaluated:
- Tenant context —
withValidatedTenantdenies when the key's user has no active membership, targets the wrong tenant, or the tenant is disabled. - Permissions — the policy layer denies when the roles lack what the route needs (e.g.
wiki:page:write).
First check the 403 actually came from the wiki. Vercel Deployment Protection, a WAF, a CDN or any reverse proxy in front of it answers 403 before a bearer token is examined — so a body naming neither wiki denial proves nothing about the credential, and the diagnosis says so rather than certifying authentication. Verify WIKI_ZONE_URL points at the intended deployment and that it is not access-protected.
When the body IS one of the wiki's own denials, read it — it is printed with the error. A permission denial names the missing permission outright; only the tenant-context denial is deliberately generic, and it is that case where you check membership before roles.
A 403 never hides a broken credential from you. Whatever the body says, the diagnosis also reports what the stored token itself looks like — expired, carrying no exp, or unparseable — because a 403 you cannot attribute is precisely the case where nothing has examined the bearer yet. There the credential defect is a second problem: fix the access protection and the next request fails with a 401 instead. Re-issue the key as well as fixing the routing.
If the 403 came from the wiki itself and the token nonetheless looks unparseable or has no exp, do not re-issue on that basis. The wiki answers 403 only after authentication succeeded, and auth-core refuses a token without exp, so the two facts cannot describe the same request — the value being inspected is not the value that was sent (a stale local copy, or the wrong secret). Reconcile that first.
Whether you must re-issue the key depends on which of the two it was:
- Membership or tenant status — checked live by
validateTenant. Re-activate the membership or re-enable the tenant and the existing key works again. Do not rotate. - Wrong tenant or insufficient roles —
signApiKeyTokenembedstenant_idandrolesas a snapshot at signing time, and the policy reads them from the token, so no Directory-side change reaches the key you already hold. Fix it, then re-issue the key and update the secret.
If instead it says the token is unexpired and well-formed, expiry is not the cause. Note that wording carefully: this check never verifies signatures, so "not expired" is not "accepted" — a corrupted key, one signed with a different secret, or a server whose API_KEY_JWT_SECRET is unset or rotated all produce a 401 that lands there. The message names those causes first, then the one configuration check that applies: that the zone URL points where you think it does. On the tenant it is precise. validateJwt requires tenant_id to be present but never compares it to the target — that matching happens afterwards in withValidatedTenant, which denies with 403 — so a tenant the key's owner belongs to but that is wrong for the target cannot produce a 401. But since INF-372 every consumer passes a principalCheck, and that check runs inside validateJwt: a claim tenant the owner holds no ACTIVE membership in (USER_NOT_IN_TENANT), or one that is disabled (TENANT_DISABLED), is a 401, as is a suspended owner (USER_SUSPENDED) or a row-expired or revoked key. The message names those liveness causes on the enforced path.
A 401 is attributed before expiry is blamed. Only a body carrying the wiki's own UNAUTHORIZED envelope means the wiki rejected the token. Vercel deployment protection answers 401 before any bearer is examined — the -planet-b2-b wiki aliases are protected that way — so against one of those hosts an expired key is a second problem and the first thing to check is that WIKI_ZONE_URL points at the intended, unprotected deployment. Rotating first there leaves the sync failing on the same 401.
Revocation is enforced by every consumer since INF-372 — the wiki was the last zone without a principal check, so a revoked-but-unexpired key now 401s there within the check's 30-second cache window, same as Directory and PT. When a wiki 401 does not match an expiry, identity.api_keys.revoked_at is a legitimate suspect; this monitor still cannot see it (see Known limits below), so check the row, not the token.
Known limits
- Exit codes.
0every watched credential is unexpired and structurally well-formed — not a certificate that it is accepted, because this monitor never verifies signatures ·1a credential NEEDS ATTENTION — read its per-token remediation rather than assuming rotation, since exit 1 also covers a truncated or unreadable secret, a wrong credential type and missing required claims, and re-storing a fresh key the same broken way reproduces the fault ·2the check is INCOMPLETE — at least one watched secret never reached the job ·3both at once ·4the monitor did NOT COMPLETE — it crashed before finishing, so its exit code is not a finding about any credential: nothing here says a token is broken, and nothing says it is fine. Fix the monitor and re-run; do not rotate on the strength of a4. Note what2does not mean: a credential that WAS present is still conclusively checked and reported, so read the attached report for which ones were covered rather than discarding the run. What you must not do is treat an unchecked credential as healthy. The alert issue carries a different headline for each, because they need different actions. - Revocation is not detected by this monitor — enforcement is the consumers' principal check.
revoked_atlives in the database, not in the token, so the expiry check cannot see it. It is consulted only by a consumer that passes aprincipalChecktovalidateJwt— which, since INF-372, every zone does: the wiki's middleware wires the shared check on both bearer paths (apps/wiki/src/middleware.ts), so a revoked key stops authenticating there within the check's 30-second cache window, exactly as in Directory and PT. - The check reads tokens without verifying their signatures. It is a diagnostic, never an authorization input. Real verification is
validateJwtin@constellation-platform/auth-core. last_used_atwas historically unreliable for wiki-facing keys — it stayedNULLon keys the wiki accepted for a month, because the wiki wired no principal check (the hook that touches it) until INF-372. Expect it to populate for wiki traffic going forward, but re-verify on a real key before relying on it, and never conclude a key is unused from aNULLrecorded before the cutover.