Skip to main content
Accepted on the owner’s decision of 2026-10-30 (Linear STA-1677). It amends 0134 (decision 2, the classes), 0380 (the built-in classes and their effects) and 0341 (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.