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.

  1. 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-self carries 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-self carries 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 way update_account_request already 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 decision set-default-party already states.
  2. Regenerate ores.cpp.protocol and ores.ts.protocol for the model, one address at a time, and check the generated files carry the new types.
  3. Handlers: account_operations_handler::update_self and account_contact_information_handler::update_self_account_contact_information both resolve the account from the bearer token — the pattern update_email already uses. The unowned-field refusal happens here, before any service call, and names each field.
  4. Services: account_operations_service::update_self_account reads the caller's account, writes the three owned fields with the reason, and nothing else. account_contact_information_service::update_self_account_contact_information finds the record by account id, creates one when the member has none, applies the intent and the precondition, and writes.
  5. Registrars: the iam core registrars subscribe the two new subjects.
  6. 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_service and account_operations_handler, one handler and one queue subscriber for the two subjects. The Plan placed the contact write in a separate account_contact_information_service method; 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 denied with code field_not_self_writable and 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-self writes full_name, job_title and image_id on the caller's own account. A value on the three fields an administrator owns is refused by name with field_not_self_writable, and the write states the owned fields whole: an empty string clears the field.
  • iam.v1.account_contact_informations.update-self writes 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.

Emacs 29.3 (Org mode 9.6.15)