Task: Add the profile self-service subjects
Table of Contents
This page documents a task in the Implement the profile journeys in ores.web story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
A member cannot write their own profile: iam.v1.accounts.update and iam.v1.account_contact_informations.put need tenant-administrator permissions, so two of the three Profile journeys are read-only for the person they are about. This task adds the two self-service subjects, iam.v1.accounts.update-self and iam.v1.account_contact_informations.update-self, each restricted to the caller's own account and to the fields a member owns, so the screen can keep its promises.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Implement the profile journeys in ores.web |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-10-04 |
3. Acceptance
- iam.v1.accounts.update-self writes full_name, job_title and image_id on the caller's own account, and no other field.
- iam.v1.account_contact_informations.update-self writes the caller's own contact record: street lines, city, state, country code, postal code, phone, email and web page.
- Each subject acts on the account of the caller's session and on no other account.
- A field the member does not own is refused with a code the screen branches on, and the refusal names the field.
- Both writes carry the shared change-reason flow, so the reason is recorded and the record gains a new version.
- No tenant-administrator permission is needed: any signed-in account may write its own record.
- Tests prove the own write lands and the refused field does not.
- The change regenerates cleanly and the iam suites pass.
4. Plan
Both writes have one shape: the session names the account, the body states the fields, and the server holds the list of fields a member owns. Neither message carries an account id, so neither can name another account.
- Declare both pairs in
ores.iam.account_messages.org, beside the self verbs already there (iam.v1.accounts.update-email,iam.v1.accounts.set-default-party,iam.v1.accounts.change-password).iam.v1.accounts.update-selfcarries the three fields a member owns (full_name,job_title,image_id) and the three only an administrator may change (email,default_party_id,reports_to_account_id). The unowned fields are declared so a stated value is refused by name; the server never drops a field in silence. The reply is the shared result plus the account as written, so the panel can show the new version.iam.v1.account_contact_informations.update-selfcarries the nine contact fields. No record id: the record is found from the session, and a member who has none gets one on the first write.- Both requests state the change reason as the model's own flat fields
(
change_reason_code,change_commentary), the wayupdate_account_requestalready does. The model declares the shell facet, whose commands fill request fields from single tokens, so a structured intent or precondition in a request is refused at generation time; nothing in the acceptance asks for a version-checked write, and the reply still returns the record with the version the store gave it. - Neither message states
http_route: the screen reaches the subjects through the BFF over NATS, which is the same per-message decisionset-default-partyalready states.
- Regenerate
ores.cpp.protocolandores.ts.protocolfor the model, one address at a time, and check the generated files carry the new types. - Handlers:
account_operations_handler::update_selfandaccount_contact_information_handler::update_self_account_contact_informationboth resolve the account from the bearer token — the patternupdate_emailalready uses. The unowned-field refusal happens here, before any service call, and names each field. - Services:
account_operations_service::update_self_accountreads the caller's account, writes the three owned fields with the reason, and nothing else.account_contact_information_service::update_self_account_contact_informationfinds the record by account id, creates one when the member has none, applies the intent and the precondition, and writes. - Registrars: the iam core registrars subscribe the two new subjects.
- Tests prove the own write lands on every owned field, the refusal names each unowned field and writes nothing, and the contact record is created when missing. Then build, run the iam suites, and run the drift gates.
5. Measurements
Every number this task claims about a tree, with the command that produced it
and the commit it was measured at. A number without both is not a measurement,
because a clean-up record describes a tree and trunk moves under a branch: a
consumer count, a gate's component count and a suite's size all change with no
code changing, and the reader of a bare number cannot tell which tree it came
from. Where the raw output matters, put it in an evidence/ file beside the
task and name that file in the Command column.
| What | Value | Command | Commit |
|---|---|---|---|
| Build | ores.iam.core.lib, ores.iam.core.tests and ores.shell.iam.tests built, exit 0 |
compass build ores.iam.core.lib ores.iam.core.tests ores.shell.iam.tests |
4503af654d |
| iam suites | 3 of 3 tests passed; ores.iam.core.tests 20.21s |
compass test run -- -R 'ores\.iam\.' |
4503af654d |
| New self-write tests | All tests passed (29 assertions in 3 test cases) | ORES_TEST_PASSWORD_FAST=1 publish/bin/ores.iam.core.tests "update_self*" |
4503af654d |
| Branch-local drift sweep | Sweep clean: no file this branch touched would change. | check_component_drift.py --sweep |
4503af654d |
| Full local drift gate | 1 drifted file: projects/ores.trading/core/src/service/trade_service.cpp, a clang-format 21.1.8-vs-18.1.3 reflow in a file this branch does not touch |
check_component_drift.py --all |
4503af654d |
| Build, review settlement | ores.iam.core.lib, ores.iam.core.tests and ores.shell.iam.tests built, exit 0 |
compass build ores.iam.core.lib ores.iam.core.tests ores.shell.iam.tests |
7b1649c2ca |
| New self-write tests, review settlement | All tests passed (53 assertions in 7 test cases) | ORES_TEST_PASSWORD_FAST=1 publish/bin/ores.iam.core.tests "update_self*" |
7b1649c2ca |
| iam suites, review settlement | 3 of 3 tests passed; ores.iam.core.tests 20.48s |
ctest --test-dir build/output/linux-clang-debug-make -R 'ores\.iam\.', the pass compass test run wraps |
7b1649c2ca |
| Branch-local drift sweep, review settlement | Sweep clean: no file this branch touched would change. | check_component_drift.py --sweep |
7b1649c2ca |
6. Notes
- Both self writes live in
account_operations_serviceandaccount_operations_handler, one handler and one queue subscriber for the two subjects. The Plan placed the contact write in a separateaccount_contact_information_servicemethod; the shared home avoids a second service that would hold the same three collaborators. - The unowned-field refusal runs inside the service, before any repository
write, and returns
deniedwith codefield_not_self_writableand one field entry per refused field. The Plan placed the refusal in the handler; the service keeps the field list beside the field mapping it already holds.
7. Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario)
that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
8. PRs
| PR | Title |
|---|---|
| #2632 | [iam] Add the profile self-service writes for members |
9. Review
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | Full-replace semantics can erase data; document on the request message or treat empty as unchanged | account_operations_service.cpp |
Accepted | Kept the whole-write and documented it on both request messages: an empty string replaces an owned field with nothing, and on an unowned field it means the field is not stated. The model change regenerated the C++ and TypeScript protocol comments. |
| 2 | No input validation on the contact write or on full_name |
account_operations_service.cpp |
Accepted | A stated contact email goes through validation::email_validator; a stated country code must be two characters (ISO 3166-1 alpha-2). A failure refuses the write with invalid_field_value and the field named. The free-text fields have no validator in the repo and stay free text. |
| 3 | A missing field cannot be told apart from an empty one; state the rule in the message model | ores.iam.account_messages.org |
Accepted | The model now says: an empty string is not a stated value, it means the field is not stated. |
| 4 | Duplicated handler code across the two self writes | account_operations_handler.hpp |
Accepted | One private self_request_scope() resolves the caller's account and context, or answers the refusal itself. |
| 5 | A token subject that is not a UUID becomes internal_error, and e.what() goes to the client |
account_operations_handler.hpp |
Accepted | A subject that is not an account id now answers unauthorized and logs a warning with the subject. The catch block answers a fixed message; the store's text stays in the log. |
| 6 | A race on the first contact write could create two records | iam_account_contact_informations_create.sql |
No action | The store already enforces one live record per account: account_contact_informations_account_id_uniq_idx on (tenant_id, account_id) where valid_to = ores_utility_infinity_timestamp_fn(). A racing create fails at the store rather than producing a second live record. |
| 7 | Test gaps: invalid image_id, missing account, update of an existing record; handler paths untested |
service_account_service_tests.cpp |
Accepted | Four service tests added, one per gap plus a bad email with a bad country code. Handler-level tests declined: the repo has no NATS test double, and the handler is a thin adapter whose token and context paths are shared with every handler in the file. |
Verdict: ready to merge. No blocking bugs; the one point with no code change (6) is settled by the schema.
10. Result
The two self write subjects are live, with the review's seven points settled in 7b1649c2ca:
iam.v1.accounts.update-selfwritesfull_name,job_titleandimage_idon the caller's own account. A value on the three fields an administrator owns is refused by name withfield_not_self_writable, and the write states the owned fields whole: an empty string clears the field.iam.v1.account_contact_informations.update-selfwrites the nine contact fields and creates the record on the member's first write. It finds the record from the session alone, refuses a bad email or country code with the field named, and the store keeps one live record per account.
Both take the account from the validated token and act on no other account, and both carry the shared change-reason flow. They are served from the hand-written handler and subscribed in the hand-written registrar; the model regenerated into the C++ and TypeScript protocols and the shell commands.
Tests: seven service tests over the two writes (53 assertions), the iam suites 3 of 3, the branch-local drift sweep clean, and all 16 CI checks green on 77c5826318.