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
credentialis a class.DataClassification.Credential(credential, appended last, so stored codes stay) and a built-inCredentialDataClassin@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).- Always encrypted at rest. A
credentialfield is named byHasEncryptedFields. gen refuses a model where it is not, withcredential_field_not_encryptedand the fix “add the field toHasEncryptedFields”. - Never exported, never answered to a subject. The panel export drops a
credentialcolumn even when it is opted in and the reader holds the opt-in permission. A tenant export or backup built on@stackra/data-transferbuilds each model’s sheet withexportSheet, which never writes acredentialcolumn 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:personalDataDeclarationsomits every field a model classifiescredential. - Deleted with its owning record, never by a person’s erasure. Erasure skips a
credentialfield (it is neither shredded, deleted nor anonymised by the saga); the secret goes when the connection or integration row that owns it is deleted. - Never seen by AI, never a report column.
routeModelrefuses a request carrying thecredentialclass (class_closed) andallowFieldslets none of its fields through, whatever the route table or allow-list says.hiddenColumns(and sohiddenReportColumns) hides acredentialcolumn even from the holder of its permission, so it is never a dimension or a measure a reader sees. - Every read is access-logged, and fails closed. A read of a
credentialis written to the access log, and a lost record refuses the read (AccessLogService.failsClosedis true forcredentialwhateverAccessLogConfig.failClosedsays). - The effect floor is code and the strictest on every axis.
CredentialDataClass:ai: never,support: hidden,search: no_snippet,export: excluded.ExportLabelgainsexcludedfor it. Like 0380 decision 3, a seed record or a tenant may not loosen it. - How a field declares it.
@Classified(DataClassification.Credential)besideHasEncryptedFields("<field>")on the model. The access log and catalogue manifests take the code with no further change (their schemas read the enum). - How OS migrates. OS replaces
DataClassification.PersonalwithDataClassification.Credentialon every provider secret field of its integrations models, and adds the field toHasEncryptedFieldswhere 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-logclassificationcheck 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, andregulatedfor 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
credentialread 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
PersonalDatais 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
exportSheetis 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 itcredential.
Enforcement
- gen:
checkEncryptedFieldsreportscredential_field_not_encrypted(packages/developer/gen/src/core/functions/__tests__/check-encrypted-fields.function.test.ts). - Export:
exportColumnstest “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.tsin@stackra/data-transfer(“writes an opted-in class, but never a credential, even opted in”). - Subject access and erasure:
personal-data-declarations.function.test.tsin@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) andhidden-report-columns.function.test.ts(@stackra/reports). - AI:
route-model.function.test.tsandallow-fields.function.test.ts(@stackra/ai). - Classes:
data-classification.enum.test.ts(@stackra/contracts) anddata-class.base.test.ts(@stackra/model). - planned: a seed-loader check that refuses a
data-class:credentialrecord 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-inDataClass). Source:
the owner’s decision of 2026-10-30, Linear STA-1677.