> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 0494 — A credential data class for provider secrets

> Accepted · 2026-10-30

> [!NOTE]
>
> Accepted on the owner's decision of 2026-10-30 (Linear STA-1677). It amends
> [0134](https://docs.stackra.figentra.com/adr/0134-data-invariants-classification-and-delete-lifecycle.md) (decision 2, the classes),
> [0380](https://docs.stackra.figentra.com/adr/0380-data-class-effects-for-the-built-in-classes-seeded-as-data-over-a-code-floor.md)
> (the built-in classes and their effects) and
> [0341](https://docs.stackra.figentra.com/adr/0341-product-defined-data-classes-and-the-privileged-class.md) (the second built-in
> `DataClass`) by Notes. The number 0494 may be renumbered when this lands: 0480 to 0493 are in use
> on other branches.

## Context

Figentra OS integrations store the secrets a provider hands over: OAuth access and refresh tokens, client secrets, API
keys, PKCE verifiers and webhook signing secrets. Today OS marks these fields `@Classified(DataClassification.Personal)`,
because no class fits. That is wrong in three ways. A `personal` field is in the panel export when a column opts in and in
a subject-access (DSAR) response. It is erased with a person, though a token belongs to the connection, not to anyone's
right of erasure. And its access log is the log of personal data, not of a secret. `DataClassification` had `personal`,
`financial`, `health`, `pseudonymous` and `regulated`; none of them means "never leaves the system".

## Decision

1. **`credential` is a class.** `DataClassification.Credential` (`credential`, appended last, so stored codes stay) and a
   built-in `CredentialDataClass` in `@stackra/model` (`src/core/access/credential.data-class.ts`). The enum value is what
   export, subject access, erasure, the access log and column visibility read through `@Classified`; the data class is what
   the AI context builder, support access and the search indexer read through `@Classify`. A field holding a provider secret
   carries `@Classified(DataClassification.Credential)` and may also carry `@Classify(CredentialDataClass)`.
2. **Always encrypted at rest.** A `credential` field is named by `HasEncryptedFields`. gen refuses a model where it is not,
   with `credential_field_not_encrypted` and the fix "add the field to `HasEncryptedFields`".
3. **Never exported, never answered to a subject.** The panel export drops a `credential` column even when it is opted in
   and the reader holds the opt-in permission. A tenant export or backup built on `@stackra/data-transfer` builds each
   model's sheet with `exportSheet`, which never writes a `credential` column either, whatever the export opts in. The
   warehouse sink never captures one (it captures no encrypted or classified field). The subject-access export never
   reads one: `personalDataDeclarations` omits every field a model classifies `credential`.
4. **Deleted with its owning record, never by a person's erasure.** Erasure skips a `credential` field (it is neither
   shredded, deleted nor anonymised by the saga); the secret goes when the connection or integration row that owns it is
   deleted.
5. **Never seen by AI, never a report column.** `routeModel` refuses a request carrying the `credential` class
   (`class_closed`) and `allowFields` lets none of its fields through, whatever the route table or allow-list says.
   `hiddenColumns` (and so `hiddenReportColumns`) hides a `credential` column even from the holder of its permission, so it
   is never a dimension or a measure a reader sees.
6. **Every read is access-logged, and fails closed.** A read of a `credential` is written to the access log, and a lost
   record refuses the read (`AccessLogService.failsClosed` is true for `credential` whatever `AccessLogConfig.failClosed`
   says).
7. **The effect floor is code and the strictest on every axis.** `CredentialDataClass`: `ai: never`, `support: hidden`,
   `search: no_snippet`, `export: excluded`. `ExportLabel` gains `excluded` for it. Like 0380 decision 3, a seed record or
   a tenant may not loosen it.
8. **How a field declares it.** `@Classified(DataClassification.Credential)` beside `HasEncryptedFields("<field>")`
   on the model. The access log and catalogue manifests take the code with no further change (their schemas read the
   enum).
9. **How OS migrates.** OS replaces `DataClassification.Personal` with `DataClassification.Credential` on every provider
   secret field of its integrations models, and adds the field to `HasEncryptedFields` where it is not. That is OS's own
   change, made after it takes the package versions that carry this ADR; nothing here edits OS. The access-log
   `classification` check constraint is built from the enum, so a new install has the value; a table that already exists
   gains it through access-log migration 0004, which replaces the check with every code (credential, and `regulated`
   for a table older than STA-1300), unvalidated, since every existing row already passed the narrower check.

## Consequences

* A provider secret no longer reaches an export, a DSAR zip or an agent by a column opt-in or a permissive route table.
* A person's erasure leaves their connections' secrets in place; the connection's own deletion removes them. A product that
  wants a secret gone with the person deletes the connection in its own erasure step.
* A `credential` read is slower to fail: a sink outage refuses it. That is the cost of "every read is logged".
* A declaration that lists only credential fields in `PersonalData` is silently empty; declare the connection's personal
  fields, not its secrets.
* Limits: data-transfer's writers receive finished sheets, so an exporter that builds a sheet by hand instead of through
  `exportSheet` is not checked (OS data-exchange builds its export and backup sheets through it). A logged read relies on
  the reading action or query declaring `@AccessLogged({ classification: Credential })`, and erasure skips a field only
  when the model classifies it `credential`.

## Enforcement

* gen: `checkEncryptedFields` reports `credential_field_not_encrypted`
  (`packages/developer/gen/src/core/functions/__tests__/check-encrypted-fields.function.test.ts`).
* Export: `exportColumns` test "never writes a credential column, even when opted in"
  (`packages/experience/panel/src/node/functions/__tests__/export-columns.function.test.ts`).
* Tenant exports and backups: `export-sheet.function.test.ts` in `@stackra/data-transfer` ("writes an opted-in class, but
  never a credential, even opted in").
* Subject access and erasure: `personal-data-declarations.function.test.ts` in `@stackra/privacy`.
* Access log: `access-log.service.test.ts` ("always refuses a credential read whose record is lost").
* Column visibility: `hidden-columns.function.test.ts` (`@stackra/iam`) and `hidden-report-columns.function.test.ts`
  (`@stackra/reports`).
* AI: `route-model.function.test.ts` and `allow-fields.function.test.ts` (`@stackra/ai`).
* Classes: `data-classification.enum.test.ts` (`@stackra/contracts`) and `data-class.base.test.ts` (`@stackra/model`).
* planned: a seed-loader check that refuses a `data-class:credential` record below this floor (the 0380 seed registry
  does not ship yet).

## Supersedes and sources

Supersedes: none. Amends: 0134 decision 2, 0380 decision 2 (the built-in classes) and 0341 (built-in `DataClass`). Source:
the owner's decision of 2026-10-30, Linear STA-1677.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.