Task: Build the profile screen

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

The three Profile journeys run in ores.web as one screen with panels, on the journey runtime the Setup group landed: the member sets their own photo, name, job title and contact details, the tenant administrator corrects a colleague's, each writable panel saves itself with its own reason, and a reporting-line change is drawn as a proposal that states who decides.

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-05

3. Acceptance

  • The member writes their own photo, name, job title and contact details against the real server through the self-service subjects, and the tenant administrator writes a colleague's through the administrator subjects.
  • Each writable panel carries its own save and its own change reason, so a half-finished edit never rides along with the record that is ready.
  • Where the screen sends a pair of writes, a failure names the record that failed, and nothing on the screen claims the pair is atomic.
  • A refusal is drawn from the server's code, so the screen branches on field_not_self_writable and says what it means.
  • The photo is replaced through the upload path: the picker states the accepted formats and size, previews at the size other screens show the photo, and the server enforces the same rule.
  • A reporting-line change is drawn as a proposal naming who must approve it; the value does not change while the proposal waits, and no approval is claimed that nothing performs.
  • Every panel the caller may not write says why, and the sign-in and access panel is a read-only summary that points at its owning journeys.
  • The two email fields are labelled apart: the sign-in address and the contact address, neither reading as the other.
  • A signed-in person reaches the journeys from the shell, and the trigger that opens them shows the person's own name and photo once the account has been read.
  • Every gap the three journey documents record is closed or stated on the screen, and the statement is recorded.
  • The screen follows the UX language, the UI design principles and the TypeScript Clean Standard, and reuses the journey runtime without adding a journey entity.

4. Plan

The three journeys run as one page with panels. Variant B of the prototype is accepted: each writable panel carries its own save and its own change reason. No route sends a pair of writes, so the partial state the prototype drew cannot arise, and the screen shows each panel's own outcome and its own new version.

The page is a journey that is a page. The rail runtime does not render it, and it adds no journey entity: the route, the panels and the operations are ordinary screen work, in the shape Journey runtime states: one hand-written BFF route per operation, each parsing a generated schema.

4.1. Reads

  1. The account read is iam.v1.accounts.get through GET /api/accounts/:username, the read the journeys already record. The member reads the username the session carries; the administrator reads the person the search picked. The person search is GET /api/accounts, the tenant list the directory already reads, filtered in the browser by username and name.
  2. The account read needs iam::accounts:read, which the default Viewer role does not hold: a plain member's read of their own account is refused. The screen states that refusal and its consequence. The identity panel offers no write it cannot ground, because an empty field clears the value on the self write, and the shell trigger falls back to the username and the initials. The reconnaissance found this gap; the screen states it, and the task records it in the journey documents.
  3. The contact read is iam.v1.account_contact_informations.list_by_account_id, which carries no permission guard: GET /api/me/contact-information for the member's own record (the session names the account, so the route takes no id) and GET /api/accounts/:accountId/contact-information for the administrator, mirroring the /api/me/access pair.
  4. The rule the picker states is a read and not a copy: GET /api/image-upload-policy answers the formats, the largest size and the smallest dimensions from the validator that enforces them.

4.2. Writes

  1. Member, account: PUT /api/me/profile calls iam.v1.accounts.update-self with full_name, job_title, image_id and the reason. The three fields a member does not own go empty, which the protocol defines as not stated, and the session names the account, so the body carries no account id.
  2. Member, contact: PUT /api/me/contact-information calls iam.v1.account_contact_informations.update-self with the nine fields and the reason. It names no record; a member without one gets it on the first write.
  3. Administrator, account: PUT /api/accounts/:username/profile calls iam.v1.accounts.update, which is a whole-record write. The route reads the account inside the request, applies the panel's three fields to that read, echoes email, default_party_id and reports_to_account_id as read, and sends the record whole. The server asks for iam::accounts:update, which the tenant-administrator role holds.
  4. Administrator, contact: PUT /api/accounts/:accountId/contact-information calls iam.v1.account_contact_informations.put with the version the panel showed as its claim: must_match_version when the panel showed a record, must_not_exist when it showed none, with the id minted by the route. The store decides the claim in the write's own transaction, so a record that moved under the save is refused with the server's own code and message, and the panel says the record changed.
  5. The photo is uploaded first, through POST /api/images (assets.v1.images.upload, the bytes base64 and their media type). The upload sets no photo: the returned id rides into the panel's own save. A refused upload branches on the server's code (unsupported_media_type, image_too_large, image_too_small, invalid_image).
  6. A refusal the panel must read with its code travels as an answer, not a failed call: the reply carries the result — outcome, code, message and the field failures — and the browser parses it with a schema of its own, because the shared result envelope drops fields on purpose. The screen draws each field failure from the server's code, and field_not_self_writable reads as the prototype's sentence, naming the field and the administrator who owns it. A call the permission does not cover is refused by the server, and the panel shows the message it carries.

4.3. Panels and statements

  1. The identity panel shows the photo, the username and the sign-in address read-only with their reasons, the full name, the job title and the reporting line. A reporting-line change is drawn as a proposal beside the value it would replace: it names who must approve (the person's hierarchical superior or a tenant administrator), states that nothing in the platform holds an approval today, and points at Draw the reporting line. The value does not move while the proposal waits.
  2. The two email fields are labelled apart: the sign-in address (account.email, owned by an administrator) and the contact address (the contact record's email, the member's own), neither reading as the other.
  3. The sign-in and access panel is a read-only summary. It names its owners and points at Protect my account (/security) and Know what I may do (/access).
  4. The page reads the caller's own grants (GET /api/me/access, the read My access already makes) only to decide which panels to draw: the person search and the administrator saves appear for a holder of a write permission. The server still refuses a call the permission does not cover.
  5. The shell's account menu gains the My profile entry, and its trigger shows the person's own name and photo once the account has been read.

4.4. Where the gaps land

  1. Closed: no self-service update (task Add the profile self-service subjects), no image upload and no stated rule (task Upload a profile image), the pair write (this screen sends none), the two emails (labelled apart here). Stated and recorded: the reporting-line approval, and the read gap in item 2, recorded in the three journey documents beside their gap lists.

4.5. Order of work

  1. The wire protocol first: the browser contact schema and mapper, a result that keeps the field failures, and thin helpers over the generated subjects (the two self writes, the contact read, the administrator account write, the contact put with its claim, the upload and the policy), each with tests.
  2. The BFF routes next: each parses a generated schema, calls one helper and answers. Each is listed in the component specification's HTTP surface, and the route tests sit beside the existing ones.
  3. The web last: the api client methods, the page with its panels and states, the account-menu entry and trigger, the i18n keys in the three languages, and the page tests.
  4. Verify: build and run the suites; recreate the database; run the journeys end to end as the member and as the tenant administrator; then 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
Web suite 27 files, 260 tests, all passed npm –prefix projects/ores.web test 13e8b9e1ef
Wire-protocol suite 13 files, 114 tests, all passed npm –prefix projects/ores.web test 13e8b9e1ef
BFF suite 30 files, 192 tests, all passed npm –prefix projects/ores.web test 13e8b9e1ef
Typecheck Clean, exit 0 npm –prefix projects/ores.web run typecheck 13e8b9e1ef
Format gate Clean, exit 0 ("All matched files use Prettier code style!") npm –prefix projects/ores.web run format:check 13e8b9e1ef
Profile journeys, end to end 67 checks ran, 29 failed; every failure traced to the two platform defects the captures document node doc/agile/versions/v0/sprint_26/implement-profile-journeys/evidence/profile_journeys_check.js (raw output: evidence/profile_journeys_run.txt) 13e8b9e1ef
Drift gate, whole tree Clean: the regenerated tree matches the checked-in tree. An earlier run, before the branch merged main forward, reported one finding outside this branch, trade_service.cpp, a local clang-format 21-against-18 version artifact; the file was restored, not committed. projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/check_component_drift.py –all 13e8b9e1ef
Drift gate, sweep Sweep clean: no file this branch touched would change. 67 files elsewhere in the catalogue would change, which belongs to those components' own clean-up. projects/ores.codegen/venv/bin/python projects/ores.codegen/scripts/check_component_drift.py –sweep 13e8b9e1ef

6. Notes

The reconnaissance found a read gap: iam.v1.accounts.get needs iam::accounts:read, which the default Viewer role does not hold, so a plain member cannot read their own account. The screen states the refusal, the identity panel offers no save it cannot ground (an empty field would clear the value on the self write), and the shell trigger falls back to the username and the initials. The gap is recorded in the three journey documents beside their gap lists.

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
#2642 [web] Build the profile screen

9. Review

The local review pass over the diff, per the runbook's step 5a: the compass-code-review-pr checklist against origin/main...HEAD, and the misspell check with it.

# Comment summary File Decision Notes
1 An administrator who picks a person whose account read fails leaves the contact panel waiting forever: the contact read is held back until the account names the record, and a failed read never names one. ProfilePage.tsx Accepted The panel now takes its waiting state from the account read while the record id is unknown, and states that read's failure. A member's own record is unaffected: the self route needs no account id.
2 The contact panel appends "The record may have changed since this panel read it" to every refused write, including one that is not about a moved record. ProfilePage.tsx Accepted The sentence is drawn from the server's conflict outcome alone, which is the version_conflict and already_exists answer prepare_change sends. Any other refusal travels with its own words and nothing added.
3 The administrator's contact route answers an identifier in the path that is not one with the body's message, where the read route beside it answers 404. server.ts Accepted The path identifier is answered with notFound as the read route does, and the body keeps its own message. A route test pins the 404 and that no subject is called.
4 The picker states the size limit as a whole number of megabytes, so a limit that is not whole is rounded and may read as larger than the server enforces. ProfilePage.tsx Accepted The limit keeps its tenth, taken down rather than up. The rule still comes from the validator's own max_size_bytes, which is two megabytes today.
5 misspell-fixer . exits 1 on the tree. the sprint 25 and 26 task pages No action Every hit is pre-existing and outside this diff: those pages quote an upstream load-profile identifier misspelled in the portfolio, in review tables that already decline the correction. A run over this diff's files reports "nothing to replace" and exits 0.
6 amendReasons keeps the common category alone, so a reason another category marked for amendments would never reach the panel. client.ts No action The profile write corrects identity or contact data, and the generic reasons are the common catalogue. A category-specific reason belongs to the journey that owns that record, and none applies here today.

Verdict: ready to merge. The four accepted points are fixed in this pass; the two with no action are recorded, and neither is in the diff's own text.

10. Result

Closed 2026-10-05, with the verification the plan set, in that order.

The suites and the type checker pass: npm --prefix projects/ores.web test runs the three packages (web 27 files and 260 tests, wire-protocol 13 files and 114 tests, BFF 30 files and 192 tests, all passed), npm --prefix projects/ores.web run typecheck exits 0, and npm --prefix projects/ores.web run format:check reports every file in the style.

The deployment carried the branch under a fresh database: the services were rebuilt and restarted, ./compass.sh db recreate -y -k recreated the database, the environment was bootstrapped, and the member and the tenant administrator signed in through the BFF at http://127.0.0.1:20402.

The journeys then ran end to end over that stack: node doc/agile/versions/v0/sprint_26/implement-profile-journeys/evidence/profile_journeys_check.js drove the three journeys as both actors. 67 checks ran and 29 failed; every failure traces to one of the two platform defects the story's Decisions record and the two captures document, and none to this branch's own code. The administrator's write to a colleague's record passes — the picker's read, the account read, the profile write with its unset fields echoed — and so do the picture byte round trip, the member's reads, the read gap's refusal, the grants read, the upload policy and the refusal of the administrator's route. The member's own saves are refused, so the member's picture and contact record never land, and the administrator's checks that read them cannot pass; the one direct administrator failure is the same self-write defect, the administrator writing their own contact record. The raw output is evidence/profile_journeys_run.txt.

The self-write refusal reproduces in SQL alone, as the DDL user from the .env against ores_dev_brave_hopper, in a transaction that rolls back and keeps nothing:

begin;
do $$
declare rec ores_iam_accounts_tbl%rowtype;
begin
  select * into rec from ores_iam_accounts_tbl
    where username = 'ores_web_member'
    and valid_to = ores_utility_infinity_timestamp_fn();
  rec.modified_by := 'ores_web_member';
  insert into ores_iam_accounts_tbl select (rec).*;
end $$;
rollback;

With the self actor it raises Invalid modified_by: ores_web_member. Must be a valid account username or service role. (23503, from ores_iam_validate_account_username_fn line 41, reached through ores_iam_accounts_insert_fn line 85). The same block with rec.modified_by : 'ores_web_probe'= answers DO and exits 0. The ordering in the generated insert — the version replace first, then the modified_by validation — refuses the write, and the actor's permissions do not enter into it.

Emacs 29.3 (Org mode 9.6.15)