@micha.bigler/ui-core-micha 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
5
5
 
6
6
  const authApi = vi.hoisted(() => ({
7
7
  fetchUsersList: vi.fn(),
@@ -37,6 +37,22 @@ import { UserListComponent } from '../src/components/UserListComponent';
37
37
 
38
38
  const currentUser = { id: 99, is_superuser: true, role: 'admin' };
39
39
  const user = { id: 1, email: 'person@example.com', role: 'student' };
40
+ const ownUser = { id: 99, email: 'admin@example.com', role: 'admin' };
41
+
42
+ function renderDeleteList(props = {}) {
43
+ return render(
44
+ <UserListComponent
45
+ currentUser={currentUser}
46
+ showNewColumn={false}
47
+ showSuccessfulLoginColumn={false}
48
+ {...props}
49
+ />,
50
+ );
51
+ }
52
+
53
+ function getRowByEmail(email) {
54
+ return screen.getByRole('cell', { name: email }).closest('tr');
55
+ }
40
56
 
41
57
  function renderUserList() {
42
58
  return render(
@@ -90,3 +106,64 @@ describe('UserListComponent role updates', () => {
90
106
  expect(authApi.fetchUsersList).toHaveBeenCalledTimes(1);
91
107
  });
92
108
  });
109
+
110
+ describe('UserListComponent delete actions', () => {
111
+ beforeEach(() => {
112
+ vi.clearAllMocks();
113
+ authApi.fetchUsersList.mockResolvedValue([ownUser, user]);
114
+ });
115
+
116
+ afterEach(() => {
117
+ cleanup();
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ it('does not offer a superuser deletion of their own row', async () => {
122
+ renderDeleteList();
123
+
124
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
125
+
126
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
127
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
128
+ });
129
+
130
+ it('does not offer an admin deletion of their own row', async () => {
131
+ renderDeleteList({ currentUser: { id: 99, role: 'admin' } });
132
+
133
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
134
+
135
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
136
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
137
+ });
138
+
139
+ it('does not restore the own-row delete action when canDeleteUser returns true', async () => {
140
+ renderDeleteList({ canDeleteUser: () => true });
141
+
142
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
143
+
144
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
145
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
146
+ });
147
+
148
+ it('keeps unrelated rows and disabled delete actions when currentUser is not loaded', async () => {
149
+ authApi.fetchUsersList.mockResolvedValue([user]);
150
+ renderDeleteList({ currentUser: null });
151
+
152
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
153
+
154
+ expect(screen.getByRole('button', { name: 'Delete' }).disabled).toBe(true);
155
+ });
156
+
157
+ it('matches the own row even when the row id and current-user id have different types', async () => {
158
+ // The backend row's id can come back as a string while currentUser.id is a
159
+ // number (or vice versa) -- isOwnRow must compare by value, not by strict
160
+ // type, or this exact mismatch would silently show the button again.
161
+ authApi.fetchUsersList.mockResolvedValue([{ ...ownUser, id: String(ownUser.id) }, user]);
162
+ renderDeleteList();
163
+
164
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
165
+
166
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
167
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
168
+ });
169
+ });
@@ -0,0 +1,219 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
4
+ import i18next from 'i18next';
5
+ import { I18nextProvider } from 'react-i18next';
6
+
7
+ import apiClient from '../src/auth/apiClient';
8
+ import { AuthContext } from '../src/auth/AuthContext';
9
+ import { checkKitIntegration } from '../src/testing/checkKitIntegration';
10
+ import { uiCoreTranslations } from '../src/i18n/uiCoreTranslations';
11
+ import { LoginPage } from '../src/pages/LoginPage';
12
+
13
+ const languages = ['de', 'en', 'fr', 'sw'];
14
+ const authMethods = {
15
+ password_login: true,
16
+ password_reset: false,
17
+ signup: false,
18
+ signup_modes: [],
19
+ social_login: false,
20
+ passkey_login: false,
21
+ };
22
+
23
+ function resources() {
24
+ return Object.fromEntries(languages.map((language) => [language, {
25
+ translation: Object.fromEntries(
26
+ Object.entries(uiCoreTranslations).map(([key, values]) => [key, values[language]]),
27
+ ),
28
+ }]));
29
+ }
30
+
31
+ function makeWrapper(instance, authMethodsOverride = authMethods) {
32
+ return function FixtureWrapper({ children }) {
33
+ return (
34
+ <I18nextProvider i18n={instance}>
35
+ <AuthContext.Provider value={{
36
+ user: null,
37
+ loading: false,
38
+ login: vi.fn(),
39
+ authMethods: authMethodsOverride,
40
+ }}>
41
+ {children}
42
+ </AuthContext.Provider>
43
+ </I18nextProvider>
44
+ );
45
+ };
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
50
+ await i18next.init({
51
+ lng: 'en',
52
+ fallbackLng: false,
53
+ supportedLngs: languages,
54
+ resources: resources(),
55
+ keySeparator: false,
56
+ nsSeparator: false,
57
+ interpolation: { escapeValue: false },
58
+ });
59
+ });
60
+
61
+ afterEach(async () => {
62
+ await i18next.changeLanguage('en');
63
+ for (const language of languages) {
64
+ i18next.removeResourceBundle(language, 'translation');
65
+ i18next.addResourceBundle(language, 'translation', resources()[language].translation, true, true);
66
+ }
67
+ });
68
+
69
+ describe('checkKitIntegration', () => {
70
+ it('passes for a correctly wired app fixture', async () => {
71
+ const { findings } = await checkKitIntegration({
72
+ i18n: i18next,
73
+ wrapper: makeWrapper(i18next),
74
+ pages: [LoginPage],
75
+ });
76
+
77
+ expect(findings).toEqual([]);
78
+ });
79
+
80
+ it('does not flag a legitimate short lowercase translated word as a raw backend code', async () => {
81
+ // Auth.LOGIN_OR's German value is literally "oder" -- all-lowercase, no
82
+ // spaces, length 4 -- the exact shape a naive raw-code heuristic would
83
+ // false-positive on. Render LoginForm's sign-up/forgot-password divider
84
+ // (onSignUp truthy) in German to actually exercise that rendered text.
85
+ await i18next.changeLanguage('de');
86
+ const { findings } = await checkKitIntegration({
87
+ i18n: i18next,
88
+ wrapper: makeWrapper(i18next, { ...authMethods, signup: true, signup_modes: ['self_signup_open'] }),
89
+ pages: [LoginPage],
90
+ });
91
+
92
+ expect(findings).toEqual([]);
93
+ });
94
+
95
+ it('reports one catalogue finding for one missing language key', async () => {
96
+ const key = 'Auth.field.email.invalid';
97
+ const french = { ...resources().fr.translation };
98
+ delete french[key];
99
+ i18next.removeResourceBundle('fr', 'translation');
100
+ i18next.addResourceBundle('fr', 'translation', french, true, true);
101
+
102
+ const { findings } = await checkKitIntegration({
103
+ i18n: i18next,
104
+ wrapper: makeWrapper(i18next),
105
+ pages: [],
106
+ });
107
+
108
+ expect(findings).toHaveLength(1);
109
+ expect(findings[0]).toMatchObject({ check: 'catalogue' });
110
+ expect(findings[0].reason).toContain(`${key} for language fr`);
111
+ });
112
+
113
+ it('still reports a missing key when the consumer app configures a fallback language', async () => {
114
+ // A consumer typically sets fallbackLng (e.g. "en"), not "false" like the
115
+ // fixture above. Without forcing fallbackLng: false per-call, i18next's
116
+ // exists() would resolve the missing "fr" key through the fallback and
117
+ // read as present, masking the real gap this check exists to catch.
118
+ const key = 'Auth.field.email.invalid';
119
+ const french = { ...resources().fr.translation };
120
+ delete french[key];
121
+ i18next.removeResourceBundle('fr', 'translation');
122
+ i18next.addResourceBundle('fr', 'translation', french, true, true);
123
+ i18next.options.fallbackLng = 'en';
124
+
125
+ try {
126
+ const { findings } = await checkKitIntegration({
127
+ i18n: i18next,
128
+ wrapper: makeWrapper(i18next),
129
+ pages: [],
130
+ });
131
+
132
+ expect(findings).toHaveLength(1);
133
+ expect(findings[0]).toMatchObject({ check: 'catalogue' });
134
+ expect(findings[0].reason).toContain(`${key} for language fr`);
135
+ } finally {
136
+ i18next.options.fallbackLng = false;
137
+ }
138
+ });
139
+
140
+ it('reports when the app passes a separate i18next instance', async () => {
141
+ const separate = i18next.createInstance();
142
+ await separate.init({
143
+ lng: 'en',
144
+ fallbackLng: false,
145
+ supportedLngs: languages,
146
+ resources: resources(),
147
+ keySeparator: false,
148
+ nsSeparator: false,
149
+ });
150
+
151
+ const { findings } = await checkKitIntegration({
152
+ i18n: separate,
153
+ wrapper: makeWrapper(separate),
154
+ pages: [],
155
+ });
156
+
157
+ expect(findings.some(({ check }) => check === 'instance')).toBe(true);
158
+ });
159
+
160
+ it('reports a page when the consumer wrapper throws', async () => {
161
+ function ThrowingWrapper() {
162
+ throw new Error('provider exploded');
163
+ }
164
+
165
+ const before = apiClient.defaults.adapter;
166
+ const { findings } = await checkKitIntegration({
167
+ i18n: i18next,
168
+ wrapper: ThrowingWrapper,
169
+ pages: [LoginPage],
170
+ });
171
+
172
+ expect(findings.some(({ check, reason }) => check === 'render' && reason.includes('LoginPage'))).toBe(true);
173
+ expect(apiClient.defaults.adapter).toBe(before);
174
+ });
175
+
176
+ it('passes the exact invalid-email resolver regression through the real login page', async () => {
177
+ const { findings } = await checkKitIntegration({
178
+ i18n: i18next,
179
+ wrapper: makeWrapper(i18next),
180
+ pages: [],
181
+ });
182
+
183
+ expect(findings).toEqual([]);
184
+ });
185
+
186
+ it('restores the transport adapter after a successful check', async () => {
187
+ const before = apiClient.defaults.adapter;
188
+ await checkKitIntegration({
189
+ i18n: i18next,
190
+ wrapper: makeWrapper(i18next),
191
+ pages: [],
192
+ });
193
+ expect(apiClient.defaults.adapter).toBe(before);
194
+ });
195
+
196
+ it('no longer intercepts a real request once the check has finished', async () => {
197
+ // Proves the restore is functional, not just reference-equal: a request
198
+ // made AFTER the check completes must reach the app's own adapter, not
199
+ // the check's stub (which would reject it as "blocked" or fake a login
200
+ // failure for the allauth login path).
201
+ const sentinel = vi.fn().mockResolvedValue({ data: 'real-response', status: 200, headers: {}, config: {} });
202
+ const before = apiClient.defaults.adapter;
203
+ apiClient.defaults.adapter = sentinel;
204
+
205
+ try {
206
+ await checkKitIntegration({
207
+ i18n: i18next,
208
+ wrapper: makeWrapper(i18next),
209
+ pages: [],
210
+ });
211
+
212
+ const response = await apiClient.get('/api/some/path');
213
+ expect(sentinel).toHaveBeenCalled();
214
+ expect(response.data).toBe('real-response');
215
+ } finally {
216
+ apiClient.defaults.adapter = before;
217
+ }
218
+ });
219
+ });
@@ -0,0 +1,185 @@
1
+ # UCM-AUTH-11 — `UserListComponent` offers an admin the delete action on their own row
2
+
3
+ # A. Envelope — authored by the Expertenchat
4
+
5
+ ## Goal & expected outcome
6
+
7
+ - Goal: the user list never offers anyone the deletion of their own account.
8
+ - Expected outcome:
9
+ - In `UserListComponent`, the row of the signed-in user renders **no** delete button — not a
10
+ disabled one, none at all. Every other row behaves exactly as today (enabled or disabled per
11
+ `canDeleteUser` / the default).
12
+ - This holds for every consumer, **including one that passes its own `canDeleteUser`**
13
+ (`AccountPage` forwards it as `userListCanDeleteUser`): a prop that returns true for the own row
14
+ does not bring the button back.
15
+ - Released; the Envelope's done-state is the release (consumer bumps are separate, see Risks).
16
+
17
+ ## Context — measured 2026-09-30 against `main` at 3.9.0
18
+
19
+ - **Operator decision 2026-09-30 (from django-core-micha `DCM-AUTH-4`, dropped there): UI-only.**
20
+ The backend keeps allowing an admin's generic self-delete (`BaseUserViewSet.destroy()` refuses
21
+ only a non-admin's self-delete since dcm 2.44.1); the list simply stops offering it.
22
+ - `src/components/UserListComponent.jsx`:
23
+ - `defaultCanEdit` (~l.128) returns true for `currentUser.is_superuser` and for role `admin` on
24
+ every row, the own row included (only the `teacher` branch excludes self).
25
+ - `canDelete` (~l.151) returns `canDeleteUser(...)` when that prop is a function, otherwise
26
+ `canEdit(targetUser)`.
27
+ - The delete button (~l.438-452) renders whenever `showDeleteAction` is true and is only
28
+ `disabled={!canDelete(row)}` — so a row that may not be deleted still shows a disabled button.
29
+ - `src/pages/AccountPage.jsx` forwards `userListShowDeleteAction` (default true) and
30
+ `userListCanDeleteUser` (default null).
31
+ - Consumers measured: kerzenziehen and reimbursements render `AccountPage` without these props, so
32
+ an admin there sees an enabled delete button on their own row today. jg-ferien passes its own
33
+ `userListCanDeleteUser`, which already returns false for the own row (the button shows disabled).
34
+
35
+ ## Scope + non-goals
36
+
37
+ - In scope: the own-row rule in `UserListComponent` (identity by `id` of the row vs. the current
38
+ user), tests, CHANGELOG entry, release.
39
+ - Explicit non-goals / do-not-touch:
40
+ - No backend change (dcm untouched).
41
+ - Rows other than the own row keep today's disabled-vs-enabled behaviour; no change to
42
+ `defaultCanEdit`.
43
+ - No other action on the own row changes (edit, extra row actions).
44
+ - Consumer pin bumps are not part of this WO.
45
+
46
+ ## Tier · gates
47
+
48
+ - **Tier 3** — change inside shared-core, permission-adjacent UI.
49
+ - Precondition: none.
50
+
51
+ ## Risks
52
+
53
+ - **Reaching the two affected apps needs a major bump:** kerzenziehen and reimbursements pin
54
+ `@micha.bigler/ui-core-micha` **2.41.3**; 3.0.0 removed chart layout props (`UCM-CHART-12`). The
55
+ fix only reaches them with that bump, which is its own work per app and is classified there by
56
+ the dependency-bump rule.
57
+ - The current user may be unknown while loading; with no current user the own-row rule must not
58
+ hide buttons on other rows or throw.
59
+ - `id` types: compare the row id and the current user id so that `3` and `"3"` match.
60
+
61
+ ## Required tests to WRITE (you write them and run YOUR OWN new ones; the orchestrator's run is the gate)
62
+
63
+ In `tests/UserListComponent.test.jsx`:
64
+
65
+ 1. Superuser, no `canDeleteUser`: own row has no delete button; another row has an enabled one.
66
+ 2. Role `admin`, no `canDeleteUser`: same as 1.
67
+ 3. `canDeleteUser` returning true for every row: own row still has no delete button.
68
+ 4. Current user not yet loaded: other rows unaffected, no error.
69
+ 5. **Mutation proof:** remove the own-row rule → tests 1 and 3 fail.
70
+
71
+ ---
72
+
73
+ # B. Implementation map — filled by the Orchestrator — ADDRESSED TO THE IMPLEMENTER
74
+
75
+ ## Context package
76
+
77
+ - File: `src/components/UserListComponent.jsx` (current state, verified against `main` at `3.9.0`
78
+ post-`UCM-AUTH-10`, line numbers below are exact).
79
+ - **The fix must gate RENDERING, not just the disabled state.** `canDelete(row)` (line 151-156)
80
+ already decides enabled-vs-disabled; the delete `Button` (line 441-453) already renders
81
+ `disabled={!canDelete(row)}`. If the own-row rule were added INSIDE `canDelete`, the button would
82
+ still render, just permanently disabled — that is explicitly NOT what the Envelope asks for ("no
83
+ delete button — not a disabled one, none at all"). The rule must wrap the render, so a consumer's
84
+ `canDeleteUser` returning `true` for the own row cannot bring the button back (Required Test #3).
85
+ - Add a small helper next to `canDelete` (around line 156):
86
+ ```js
87
+ const isOwnRow = (targetUser) => {
88
+ if (currentUser?.id == null || targetUser?.id == null) return false;
89
+ return String(targetUser.id) === String(currentUser.id);
90
+ };
91
+ ```
92
+ - `currentUser?.id == null` (loose check, catches both `undefined` and `null`) covers the "current
93
+ user not yet loaded" risk: when there is no current user at all, every row's `isOwnRow` is
94
+ `false`, so nothing is hidden and nothing throws (Required Test #4) — this function must NOT be
95
+ the place that decides "hide everything while loading"; it only ever answers "is THIS row me",
96
+ and answers `false` when that question is unanswerable.
97
+ - `String(...) === String(...)` is the fix for the Risk note's `id` type mismatch (`3` vs `"3"`).
98
+ - Wire it into the existing render condition at line 438 — change
99
+ `{showDeleteAction && (` to `{showDeleteAction && !isOwnRow(row) && (` (the `Tooltip`/`Button`
100
+ JSX inside is otherwise byte-identical — do not touch its internals, only the gating condition
101
+ that decides whether that whole block renders at all).
102
+ - Do not touch `canDelete`/`canEdit`/`defaultCanEdit` (line 128-156) — the non-goal is explicit that
103
+ every OTHER row's enabled-vs-disabled behaviour stays exactly as today; this fix only removes the
104
+ own row's button, it does not change what `canDelete` returns for anyone (a consumer's
105
+ `canDeleteUser({ targetUser, currentUser, extraContext })` for the own row may still return
106
+ `true` internally — that return value is simply never consulted for the own row anymore, because
107
+ the render is gated before `canDelete(row)` is ever reached for that row's button).
108
+
109
+ ## Test file — `tests/UserListComponent.test.jsx` (existing — read it first for the render harness,
110
+ ## the `react-i18next`/`i18n` mock shape `UCM-AUTH-10` already fixed there, and `authApi` mocking
111
+ ## convention already established; extend it, do not replace its structure)
112
+
113
+ Five cases, matching the Required Tests 1-5 exactly:
114
+ 1. `currentUser = { id: 99, is_superuser: true, ... }`, `fetchUsersList` resolves a list including a
115
+ row with `id: 99` (the signed-in user) and at least one other row (e.g. `id: 1`); no
116
+ `canDeleteUser` prop passed. Assert: querying delete buttons by accessible name/role yields exactly
117
+ one (for the OTHER row), and it is not disabled; no delete button exists for the row whose `id`
118
+ is `99` (e.g. scope the query to that row's `TableRow` — check how existing tests in this file
119
+ locate a specific row, or use `within(row)` from `@testing-library/react` if not already
120
+ imported).
121
+ 2. Same shape, `currentUser.role = 'admin'` instead of `is_superuser` — same assertions.
122
+ 3. Pass `canDeleteUser={() => true}` (unconditionally true for every row, including the own one) —
123
+ assert the own row STILL has no delete button, while the other row's is enabled (proves the
124
+ render-level gate wins over a consumer prop, not just over the default `canEdit`-based logic).
125
+ 4. Render with `currentUser = null` (or omit the prop) while `fetchUsersList` resolves a list
126
+ including a row that happens to share no id with anyone — assert the component does not throw
127
+ (no unhandled rejection/render error) and renders without crashing; per the Envelope's own
128
+ framing, unauthenticated/loading state does not by itself grant any row a delete button (this
129
+ test is about NOT crashing and NOT hiding unrelated rows, not about granting new permissions —
130
+ keep whatever the existing default-canEdit-based enabled/disabled state would be for other rows
131
+ in this configuration, do not assert a specific enabled/disabled state you have not verified from
132
+ `defaultCanEdit`'s own logic for a null `currentUser`, which already returns `false` unconditionally
133
+ at line 129 — so with the default `canDeleteUser`, every row is disabled-but-present in this
134
+ configuration; assert exactly that: buttons present and disabled, none of them thrown away).
135
+ 5. **Mutation proof required by the Envelope itself:** after writing tests 1 and 3, temporarily
136
+ comment out or revert the `!isOwnRow(row)` addition in your own working copy, re-run tests 1 and
137
+ 3 ONLY, confirm both fail, then restore the fix and confirm the full focused file is green again.
138
+ Narrate this in `PROGRESS` (which two tests failed, then the restore) rather than leaving a
139
+ permanently-broken toggle in the committed test file — same pattern already used successfully in
140
+ this repo's own `UCM-THEME-15`/`UCM-PRIM-2` work orders for an identical "prove it would fail"
141
+ requirement.
142
+
143
+ ## Do-not-touch / invariants
144
+
145
+ - No backend change (dcm is a separate repo, untouched, and out of scope regardless).
146
+ - No change to `defaultCanEdit`/`canEdit`/`canDelete`'s return values for any OTHER row.
147
+ - No change to any other action on the own row (role select, extra row actions) — only the delete
148
+ button's presence.
149
+ - No consumer (`AccountPage`, etc.) touched — this fix is entirely inside `UserListComponent.jsx`.
150
+
151
+ ## Target repo working directory (absolute)
152
+
153
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`)
154
+
155
+ ## Preamble — a REQUIRED block IN this file, not something appended at invocation
156
+
157
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
158
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
159
+ > app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
160
+ > unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` — the register
161
+ > row and the review verdicts are the orchestrator's alone.** **Your tools are for editing source
162
+ > and test files and for running the tests you wrote — nothing else.** Do NOT install dependencies,
163
+ > touch a lockfile, run a package manager, or tidy up stray files; if something in the repo state
164
+ > blocks you, stop and report it as `RESULT: BLOCKED <reason>` instead of fixing it. Do NOT
165
+ > `git add`/`commit`/`push` — leave every
166
+ > change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests
167
+ > the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute
168
+ > and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review).
169
+ > The orchestrator re-runs the authoritative set + does the independent review after you finish —
170
+ > those are the gate; your own run does not count as the gate.
171
+ >
172
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
173
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
174
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
175
+ > `RESULT: DONE|BLOCKED <reason>`.
176
+
177
+ ---
178
+
179
+ # C. Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
180
+
181
+ > **If you are the implementer reading this work order as your own specification: STOP at this line.
182
+ > Everything below describes what the Orchestrator does AFTER you finish. You do none of it — no
183
+ > reviewers, no verification run, no register edit, no commit.**
184
+
185
+ *(to be filled by the Orchestrator: execution directive; `reviewer` (all lenses) + `ui_reviewer` + `sec_reviewer`; release (patch: behaviour fix of an existing component); register row stays `released` until consumers bump; register the kerzenziehen and reimbursements bumps in their own registers; commit.)*