Authority credential retention

Authority reclaims credentials only after a recorded expiry and its retention window. Expiry cleanup preserves user/client configuration, grants, revocations, issuer trust, signing-key history and security-event evidence.

The credential keeper runs immediately after startup convergence and then hourly by default. Authority:Retention configures Enabled, Interval, BatchSize (default 1,000, maximum 10,000), MaximumBatchesPerScope (default 10, maximum 100), and MaximumCredentialRowsPerTable (default 1,000,000).

StateReclamation windowScope and protection
authority.tokensSeven days after explicit expiryRead Authority’s own tenant registry, including inactive tenants, and use normal tenant-scoped connections. A token with a remaining refresh-record reference is retained.
authority.refresh_tokensThirty days after explicit expiryUnexpired credentials survive. Refresh cleanup precedes access-token cleanup; no refresh row is cascade-deleted by access-token cleanup.
authority.sessionsThirty days after explicit expiryAn ended session with a future expiry remains until the expiry window closes. User and authorization state is unaffected.
authority.oidc_tokensThirty days after recorded expiryAuthority-owned global credential registry. Null expiry is unknown and remains protected, including revoked records whose original expiry is unavailable.
authority.oidc_refresh_tokensThirty days after recorded expirySame unknown-expiry protection and bounded batches.

The original three repository expiry methods delegate to the same bounded, tenant-scoped implementation used by the keeper. Deletion rechecks the selected expiry after waiting for a concurrent writer. No RLS bypass or new database role is introduced.

Unknown expiry and capacity

The OIDC tables are operational state rather than a uniformly expiring cache: historical rows can lack the metadata needed to prove expiry. The read-only review on 2026-09-21 found 28,193 such rows in authority.oidc_tokens, last created on 2026-08-30. Their expiry is not inferred from creation time, token type or a current client lifetime.

doctor.authority.retention.credential-state reports total rows, overdue rows and unknown expiry per table. Disabled cleanup, overdue rows, unknown expiry, an exceeded capacity budget or an unmeasurable database is unhealthy. The row budget is diagnostic; it never authorizes eviction of valid or unknown credentials to make a number green.

The 21 source-record tables retain their original identity and custody role. These cover tenants/users, API keys, bootstrap invitations, clients and tenant grants, permissions and roles, service accounts, provider configuration, revocation/export continuity, verdict manifests, issuer/key/trust configuration, issuer audit and signed provider-change receipts. AuthorityAuditSink writes login_attempts before optional Timeline emission; the optional copy is not proof that the original can be rebuilt. Break-glass events are reconciled explicitly, and historical operator signing keys remain protected by their existing mutation guards.

Login-attempt event retention (DIP-32)

authority.login_attempts is the original, windowed append-audit store for AuthorityAuditSink events, including successful administration and security operations. It is not only a failed-login counter. Q-50 option B gives these records two retention windows; the optional Timeline copy is not a prerequisite for disposal and is not claimed to be a complete rebuild source. The owner settled the numeric periods on 2026-10-13: failed/security events are kept for 365 days and routine successes for seven days, including successful security or administrative actions in the longer class.

The two periods are one versioned Platform-owned JSON policy at platform.environment_settings[Retention:Authority:LoginAttempts]. Its default pair is auditDays=365, routineDays=7; both are positive integers, the audit window must be longer, and the maximum audit window is 36,500 days. Only an explicitly successful owner response that the setting is absent permits these defaults. A failed, unauthorized, malformed or stale read stops disposal, including after a longer configured policy has previously been read. There is no local configuration override or outage fallback for the retention periods.

Authority reads GET /platform/retention/authority-login-attempts through STELLAOPS_PLATFORM_URL once per pass. The immutable snapshot and UTC cutoff apply to every batch in that pass; an operator change takes effect at the next pass. Responses are uncached and carry a fresh observation time and revision. The existing single Authority token client requests platform.retention.read; Authority:LoginAttemptRetention:AuthTenant selects its tenant (default default). The source preparation adds this scope to console-workspace; it does not establish that the running identity has the grant. See Platform’s policy and audit contract.

Host transport wiring (STELLAOPS_PLATFORM_URL and AuthTenant) is captured during registration and takes effect on Authority restart. The typed immutable transport value does not cache the period policy. A missing or invalid owner URL is refused when maintenance reads the policy, so it pauses disposal without preventing authentication startup. Editable Platform periods still take effect through a fresh authoritative read at the next sweep. The shipped Compose stack and release bundle set Authority’s STELLAOPS_PLATFORM_URL to http://platform.stella-ops.local; the keeper needs it to read the policy.

Authority:LoginAttemptRetention configures the keeper’s operational limits; these are validated at startup:

SettingDefault and validation
Enabledtrue; the keeper still waits for its valid retention index.
IntervalOne hour; between one second and one day. Also the minimum persisted cooldown after a category’s last deletion before its budget can refill.
BatchSize1,000 rows; from 1 through 10,000.
MaximumBatchesPerClass10; from 1 through 100. Each category gets a durable BatchSize × MaximumBatchesPerClass row budget, plus this per-invocation batch limit.
IndexBuild:BuildTimeoutSeconds3,600; shared post-start index builder statement limit.
IndexBuild:LockTimeoutSeconds10.
IndexBuild:PollIntervalSeconds5.
IndexBuild:RetryDelaySeconds / MaxRetryDelaySeconds30 / 900; bounded retries survive restart through the migration ledger.

The short window applies only to exact outcome = 'success', a null reason (or the password handler’s exact Password grant validation succeeded. reason), and the three event types authority.client_credentials.grant, authority.password.grant and authority.plugin.standard.password_verification. Properties must be a JSON array whose entries have string Name/Value members and only the reviewed context fields listed by authority.login_attempt_is_routine in migration 037. These fields describe the subject/client, network, tenant compliance profile, correlation and scopes. A successful property, when present, must be True or true; subject.requires_password_reset must be false when present. The writer’s optional privacy metadata is also accepted: Sensitive must be a JSON boolean, and Classification must be none, personal or sensitive. These describe data sensitivity, not event outcome. Any other object member or invalid metadata retains the audit window.

Every other record takes the audit window: failures, lockouts, rate limits, errors, unknown outcomes/event types, successful administration, resource authorization, replay/tamper events, password-reset reasons, recovered failed-attempt counts, incident/policy-operation properties, unfamiliar properties and malformed property shapes. This is a conservative allowlist, not a success-flag filter. Adding a new routine event or property requires reviewing the classification and replacing the indexed function through a forward migration with a new function/index identity.

The current password writer emits authority.password.validation for preliminary credential validation and authority.password.grant after final issuance. Preliminary validation remains in the audit class even when successful. A final grant carrying pack.run_id, pack.gate_id or pack.plan_hash also remains in the audit class: these properties record a security approval, so the seven-day routine rule does not apply. The default 365-day cutoff is strict; records at the exact cutoff remain retained. Historical password-grant validation records still follow the reviewed classification above. These writer changes do not alter migration 037 or expand its routine allowlist.

Both expiry cutoffs use the injected UTC clock. Rows exactly at the cutoff, newer rows and future rows remain. The keeper waits for the exact valid retention index, then selects an ordered bounded batch with row locks and SKIP LOCKED; a transaction-scoped advisory lock prevents simultaneous replica batches. Migration 038 adds authority.login_attempt_retention_budget: at most two rows, one per Boolean category. Each committed batch charges its actual deleted-row count and records a nondecreasing database-clock deletion time in the same transaction. Repeated calls, replacement hosts, and policy revision or retention-period changes do not reset that accounting.

A category refills only after a complete cooldown without a successful deletion. This conservative rule can defer available rolling-window capacity; it never grants a second budget merely because a calendar hour changed. During an active period, smaller ceilings and longer intervals tighten the persisted limits. A ceiling below the already-spent count admits zero rows without discarding the count. Larger ceilings or shorter intervals take effect only after the greater of the stored and current cooldown has elapsed. Zero-row passes do not spend budget or extend its timestamp. The database clock owns admission; the event clock cannot grant another budget. Moving the database clock forward is outside this guarantee.

Each batch commits independently. A failed or cancelled batch rolls back both its event deletions and its budget debit; previous committed batches remain charged. The same default settings therefore cap disposal at 10,000 rows per category per hour across restarts and competing callers. Missing budget schema refuses disposal. Locks wait at most two seconds and statements at most 30 seconds. A failed pass retries after the configured interval. Only aggregate class counts and exception types are logged; metrics are authority.login_attempts.retention.removed (routine/audit) and authority.login_attempts.retention.failures.

The schema has no foreign-key children referencing login_attempts; disposal does not cascade into credentials, users, grants or other evidence. The existing capacity budget remains diagnostic and never shortens either retention window. Deletion releases logical rows for normal PostgreSQL vacuum reuse; it does not promise a smaller database file.

Migrations 037 and 038 are forward-only. Their function, table declaration and durable budget schema converge at startup; replay preserves existing events and spent budgets. Migration 038 validates an existing table before adopting it, under a table lock; it refuses incompatible columns, primary keys, bound constraints or parent/child inheritance without repairing rows. An inherited child cannot extend the category key’s row bound. Every sweep validates the same schema and bounded accounting state before any category can delete. An invalid spent count, missing timestamp for spent rows or invalid cooldown cannot manufacture disposal credit.

doctor.authority.retention.login-attempt-budget reads only the owning database. It accepts a valid empty table before first use and a spent count above a subsequently tightened ceiling. It reports missing or malformed schema/state as unhealthy. Its row read is capped at three: the third row disproves the two-category bound. This is a logical row bound, not a promise about physical files or obsolete MVCC tuple storage.

The DIP-21 shared index builder must finish the registered concurrent index before the keeper can delete. Source and isolated-fixture verification are separate from activation: live acceptance requires authorized Platform and Authority images, an authenticated owner policy response, the reviewed narrow scope grant, a valid index and an observed bounded pass on that Authority image. Policy changes also require the configured signed Timeline delivery path; persisted pending audit events remain protected until acknowledged.

Live activation on the reference estate (2026-10-16), from clean main eb045eaaf8 images:

At activation, 130,149 of 1,961,837 events classified as routine. Most successful events are authority.resource.authorize decisions, which keep the audit window. The activation receipt records the disabled-stage baseline, cohorts and verification.

Verification

The durable login-attempt budget and its Doctor guard are verified against source commit 15213635fb712222657ef766f77b07a04f2cd935. The source qualification receipt separates the final 26 budget/migration and three host controls from the earlier full-suite proof. Re-verify the owning budget cases in PowerShell:

& ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/__Tests/StellaOps.Authority.Persistence.Tests/StellaOps.Authority.Persistence.Tests.csproj -Class @('*LoginAttemptRetentionHourlyBudgetTests','*LoginAttemptRetentionBudgetMigrationTests') -BuildProjectReferences -Restore
& ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Method '*LoginAttemptBudget_UsesActualSchemaAndBoundedState_WithNativeNegativeControls' -BuildProjectReferences -Restore

The older credential-state verification is bound to source commit e6cc8c13e0d1300ae3909cc1754338e5dd6b39e7. Its dated verification receipt records the executed checks and census. Re-run AuthorityCredentialRetentionTests and AuthorityDoctorAdoptionTests in the Authority host test project using tools/scripts/test-targeted-xunit.ps1 with -BuildProjectReferences -Restore. The persistence suite verifies forward migration convergence, classification and bootstrap ordering. The five P13 gates are the source inventory, --tables, --owners, --owners --strict and --self-test modes of tools/scripts/validate/check-p13-retention.py --repo-root ..

Acceptance uses isolated PostgreSQL databases and the real composed services. No live credential cleanup or production deployment is claimed. The six unqualified identity subjects remain the separate RFC-16 review.