Story: Separate the account's credentials from its profile

Table of Contents

This page documents a story in Sprint 27. It captures the goal, current status, acceptance criteria, and the tasks that compose it.

1. Goal

A profile write can never change a credential, and the history says which of the two actually changed.

2. Status

Field Value
State DONE
Parent sprint Sprint 27
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-10-06

3. Acceptance

  • Account credentials live in their own entity, ores.iam.account_credential.
  • A profile update cannot clear a credential, because it cannot reach it.
  • No credential column travels on the wire.

4. Tasks

Task State Start End Description
Scaffold story: Separate the account's credentials from its profile DONE 2026-10-06 2026-10-07 Story scaffolding rides this task: documents, sprint wiring, and the scaffold PR. Close it before merging that PR.
Give the account a credential entity of its own DONE 2026-10-06 2026-10-07 Initial task for: Separate the account's credentials from its profile

5. Decisions

  • The credential entity is projected across the whole stack, exactly as the account is. The first cut withdrew every wire facet and left only the SQL schema, the domain type, the generator and the repository. That was wrong. "No credential column travels on the wire" is what :no_wire: is for: it keeps the member in C++ and skips it when the struct is serialised, so the entity can be projected like any other and still carry nothing. Removing the surface sidestepped the mechanism rather than using it. See the task's
    • Not done, and why.
  • The credential's generated half is reads-only, and that is the specification's answer, not ours. :read_only: true, because the resource has no write record: the spec says a write carries "the fields the user owns, and no others", and that a server-owned field never appears in one, so "a read-only resource simply declares no write verb". A generated update would also deserialise a record whose :no_wire: secrets were skipped and replace the row whole, which is the blanking bug this story exists to kill.
  • The messages are addressed where the specification says. Reads are common entity operations on the resource's own grammar, iam.v1.account_credentials.* with a verb from the closed set of eight. The writes are not common entity operations, so they go in the reserved domain-operation namespace, iam.v1.ops.*, whose discriminator the spec states outright: "an operation that acts on an entity the common way belongs in the common set, and an operation that merely has an unusual name does not qualify as a domain operation." A password write fails both tests, which is why iam.v1.accounts.change-password becomes iam.v1.ops.change_password; the census story owns that move and has it in flight, and this story does not duplicate it.
  • The resource carries both codes. iam::account_credentials:read is what the generated reads check, so an administrator may ask which accounts hold a credential and when it changed, and may not see one. iam::account_credentials:write gates the administrative password reset, deliberately separate from iam::accounts:reset_password: a role that may edit a profile cannot thereby change what an account signs in with.
  • Writes stay hand-written. A credential write is a service method over account_credential_repository::write, reached by an operation the account already serves. Whether those operations are declared in a model of their own is a separate question, and belongs with the migration rather than with this story. Change-password and reset-password are built; TOTP enrolment, verification and disable are not.
  • A secret that must cross the wire travels on a purpose-built message. TOTP enrolment hands the person a secret to scan, and :no_wire: skips the member in both directions, so it cannot be a field of the credential record. It is its own operation field, as password is on save_account_request.
  • password_salt is gone. The stored hash embeds its own salt, nothing read the column, and every writer wrote it empty. It is not carried into the new entity.
  • A credential change does not bump the account. The credential is a child of the account in the SQL sense, but its FK is not :bump_parent_version:, so a password change leaves the profile's history alone and the two rows keep independent version streams.
  • A nullable skipped column reads through get(). The generator emitted v.secret_hash.empty() against an rfl::Skip, which does not compile. The mapper template now branches on :no_wire: and reads the wrapped value. projects/ores.codegen/tests/test_no_wire_columns.py holds it.

6. Out of scope

  • account_type and account_status. They are not secrets, they round-trip through the generated domain type, and moving them is a separate story.

7. See also

8. Result

The account's profile and its secrets are two entities with two access paths. ores.iam.account declares every column it holds, so a whole-row write through it is lossless and cannot reach a credential, because there is none on the row. ores.iam.account_credential holds the secret material with every column :no_wire:, is projected across the whole stack, and declares no write verb because a credential has no write record to carry. password_salt is gone: the stored hash embeds its own salt and nothing read the column.

Both properties the story is named for were measured on the running system. A profile write took an account from version 2 to version 3 while its credential row kept the same id, version and service_password_hash; the credential reads answered with 25 records and no secret field in any of them.

Two things came out of it that are not the split itself. A nullable :no_wire: column exposed a codegen defect – the mapper emitted v.secret_hash.empty() against an rfl::Skip, which does not compile – and the mapper template now branches on the flag with a regression test. And the first cut modelled the entity as internal, with the wire facets withdrawn; that was wrong and is recorded in * Decisions.

TOTP is not built. The secret is a column with no reader and no writer, and the capability is captured in the inbox.

Emacs 29.3 (Org mode 9.6.15)