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 whyiam.v1.accounts.change-passwordbecomesiam.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:readis 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:writegates the administrative password reset, deliberately separate fromiam::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, aspasswordis onsave_account_request. password_saltis 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 emittedv.secret_hash.empty()against anrfl::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.pyholds it.
6. Out of scope
account_typeandaccount_status. They are not secrets, they round-trip through the generated domain type, and moving them is a separate story.
7. See also
- Bring every subject onto the canonical entity protocol — the census of every subject in the tree against the same specification, and the gate that will hold it. This story takes the IAM subjects it has to move anyway; the rest of the component is that story's.
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.