@ttctl/core 0.1.0-rc.3 → 0.1.0-rc.5

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,3 +1,4 @@
1
+ import type { DryRunPreview } from "../../../transport.js";
1
2
  /**
2
3
  * `Employment` row as ttctl exposes it. Trimmed read-side projection of
3
4
  * the `Employment` GraphQL fragment (see
@@ -38,6 +39,22 @@ export interface Employment {
38
39
  code: string | null;
39
40
  name: string | null;
40
41
  } | null;
42
+ /**
43
+ * The catalog id of the resolved employer (#394 — surfaced read-side so
44
+ * `update()` can echo it back through the merge; the wire's
45
+ * `UpdateEmploymentInput` requires `employerId` even when the caller
46
+ * only supplies `position`, and the read-side previously hid the id).
47
+ */
48
+ employerId: string | null;
49
+ /**
50
+ * Catalog skills attached to the row (#394 — surfaced read-side so
51
+ * `update()` can preserve them through the merge; the wire requires
52
+ * `skills` to be non-empty on update).
53
+ */
54
+ skills: {
55
+ id: string;
56
+ name: string;
57
+ }[];
41
58
  }
42
59
  /**
43
60
  * Fields editable on an Employment row. Mirrors `EmploymentInput` per the
@@ -51,9 +68,21 @@ export interface Employment {
51
68
  * fields (employerId, engagementId, industryIds, managementExperience,
52
69
  * primaryGeographyId, reportingTo, skills, …) are exposed at the type
53
70
  * level so future leaves can grow without churning callers.
71
+ *
72
+ * `employerId` is the server-side catalog identifier for the employer
73
+ * record (e.g. "V1-Employer-1234"). `add()` requires EITHER an explicit
74
+ * `employerId`, a `company` that resolves to exactly one autocomplete
75
+ * match (see {@link add} for the resolution policy), OR the
76
+ * {@link EmploymentFields.noEmployer} signal for a custom (non-catalog)
77
+ * workplace — which sends `employerId: null` with the free-text
78
+ * `company` verbatim. `employerId` and `noWebsite` are ORTHOGONAL axes
79
+ * (#401): a custom workplace may still carry a website. The earlier
80
+ * "nullable only when noWebsite" note was a single-capture
81
+ * over-inference, not the wire contract.
54
82
  */
55
83
  export interface EmploymentFields {
56
84
  company?: string;
85
+ employerId?: string;
57
86
  position?: string;
58
87
  companyWebsite?: string | null;
59
88
  noWebsite?: boolean;
@@ -67,8 +96,63 @@ export interface EmploymentFields {
67
96
  industryIds?: string[];
68
97
  primaryGeographyId?: string | null;
69
98
  reportingTo?: string | null;
70
- skills?: string[];
99
+ /**
100
+ * Catalog skill refs (wire shape: `SkillRefInput[]` = `{ id, name }[]`,
101
+ * not the `string[]` originally declared — corrected #394 after the
102
+ * live capture showed the live mutation accepts the object form and
103
+ * rejects empty arrays on update).
104
+ */
105
+ skills?: {
106
+ id: string;
107
+ name: string;
108
+ }[];
109
+ noEmployer?: boolean;
110
+ }
111
+ /**
112
+ * Options accepted by {@link add}. `dryRun` mirrors the option-shape
113
+ * established by `basic.set` (#393 / SetOptions) so the cross-service
114
+ * surface stays uniform — callers that branch on the outcome's `kind`
115
+ * discriminator can use the same code path regardless of which
116
+ * mutation they're invoking.
117
+ *
118
+ * The `dryRun` path fires the employer autocomplete read so the preview
119
+ * shows the resolved `employerId` (not the raw `company` string). This
120
+ * departs from `basic.set`'s zero-network dry-run by design (#395):
121
+ * the alternative — placeholder employerId — would misrepresent the
122
+ * wire shape, since the server-side input requires the resolved id, not
123
+ * the company name. EXCEPTION (#401): the custom-workplace path
124
+ * (`fields.noEmployer === true`) skips resolution entirely, so dry-run
125
+ * there fires ZERO network — `employerId: null` needs no lookup. The
126
+ * mutation transport is still NEVER fired in `dryRun` mode.
127
+ */
128
+ export interface AddOptions {
129
+ dryRun?: boolean;
130
+ }
131
+ /**
132
+ * Discriminated outcome of an {@link add} call when the apply-path
133
+ * succeeded — the newly created {@link Employment} row.
134
+ */
135
+ export interface AddOutcomeCreated {
136
+ kind: "created";
137
+ result: Employment;
138
+ }
139
+ /**
140
+ * Discriminated outcome of an {@link add} call invoked with
141
+ * `dryRun: true` — the structured preview of the request that WOULD
142
+ * have been sent. The `employersAutocomplete` read query MAY have been
143
+ * fired during dry-run to resolve `employerId`; the `CreateEmployment`
144
+ * mutation transport was NOT fired.
145
+ */
146
+ export interface AddOutcomePreview {
147
+ kind: "preview";
148
+ preview: DryRunPreview;
71
149
  }
150
+ /**
151
+ * Discriminated-union return type for {@link add}. Apply-path callers
152
+ * branch on `outcome.kind === "created"`; dry-run callers branch on
153
+ * `"preview"`. Symmetric with `basic.set`'s {@link SetOutcome} (#393).
154
+ */
155
+ export type AddOutcome = AddOutcomeCreated | AddOutcomePreview;
72
156
  /**
73
157
  * Lightweight `Employer` reference returned by
74
158
  * `employer-autocomplete`. Mirrors the read-side `Employer` fragment
@@ -97,12 +181,117 @@ export declare function list(token: string): Promise<Employment[]>;
97
181
  export declare function show(token: string, id: string): Promise<Employment>;
98
182
  /**
99
183
  * Create a new employment row. Wire format per Pattern 2: `{ profileId,
100
- * employment: EmploymentInput }`. `company` and `position` are required.
184
+ * employment: EmploymentInput }`.
185
+ *
186
+ * **employerId resolution (#395)**: the live `talent_profile/graphql`
187
+ * server requires `employment.employerId` (the catalog identifier),
188
+ * NOT the free-text `company` string. Pre-#395, `add({company, role,
189
+ * …})` sent only the company string and was rejected with
190
+ * `USER_ERROR: employment add rejected (employerId): You can't leave
191
+ * this empty`. The fix:
192
+ *
193
+ * 1. If `fields.employerId` is supplied → use it verbatim. This is
194
+ * the explicit-bypass path (`--employer-id` on CLI,
195
+ * `employerId` on MCP) — useful for replay scripts, the
196
+ * disambiguation fallback, or known-good ids.
197
+ * 2. Otherwise, fire {@link employerAutocomplete} against
198
+ * `fields.company`. Toptal's autocomplete is fuzzy / prefix-
199
+ * search (typing "Anthropic" returns 10 partial matches), so the
200
+ * practical cardinality is on EXACT NAME MATCH (case-insensitive
201
+ * trim):
202
+ * - exactly 1 exact match → use its id transparently
203
+ * - 2+ exact matches (catalog duplicates — e.g. multiple
204
+ * regional subsidiaries with the same display name) →
205
+ * `VALIDATION_ERROR` listing the duplicates + `--employer-id`
206
+ * nudge
207
+ * - 0 exact, 0 fuzzy → `VALIDATION_ERROR` nudging to the
208
+ * autocomplete CLI command or `--employer-id` bypass
209
+ * - 0 exact, ≥1 fuzzy → `VALIDATION_ERROR` listing the top-N
210
+ * closest candidates with `--employer-id` nudge (the user
211
+ * typed a prefix; surface the catalog's actual names)
212
+ *
213
+ * The static defaults at `experienceItems: []`, `skills: []`,
214
+ * `showViaToptal: true` are retained pre-#395 — they were established
215
+ * empirically via the #344 E2E to satisfy "Expected value to not be
216
+ * null" on those required fields. Reviewing them in this PR would
217
+ * be premature without a fresh live capture; they may be revisited in
218
+ * a follow-up that mirrors the basic.set #393 read-merge pattern for
219
+ * employment.add.
220
+ *
221
+ * **Dry-run path (#395)**: when `options.dryRun === true`, the
222
+ * employer resolution still runs (fires `employersAutocomplete` if
223
+ * `employerId` is absent — except on the #401 custom-workplace path;
224
+ * see **Custom-workplace path (#401)** below) so the preview's
225
+ * `variables.input.employment`
226
+ * carries the resolved `employerId`, matching the wire shape the live
227
+ * mutation would transmit. The `CreateEmployment` mutation transport
228
+ * is NOT invoked. The placeholder
229
+ * {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} stands in for `profileId`
230
+ * (which the apply-path resolves via `extractProfileId`).
231
+ *
232
+ * **Custom-workplace path (#401)**: when `fields.noEmployer === true`,
233
+ * `resolveEmployerId()` is skipped entirely — NO `employersAutocomplete`
234
+ * call in EITHER the apply or dry-run path — and `employerId: null` is
235
+ * sent with the free-text `company`. Mutually exclusive with an explicit
236
+ * `fields.employerId` (→ `VALIDATION_ERROR`). Orthogonal to `noWebsite`.
237
+ */
238
+ export declare function add(token: string, fields: EmploymentFields, options?: AddOptions): Promise<AddOutcome>;
239
+ /**
240
+ * Placeholder string substituted into a dry-run `UpdateEmployment`
241
+ * preview's variables payload for fields that the apply-path resolves by
242
+ * reading the current row (`experienceItems`, `position`, `skills`,
243
+ * `showViaToptal`, `startDate` — the five required-non-null fields
244
+ * injected by the read-current+merge logic, #394 + #407 for `position`).
245
+ * Surfaced verbatim so MCP consumers can
246
+ * see the structural shape of what will be sent without TTCtl having
247
+ * fired the read transport. Same posture as `basic.set`'s
248
+ * {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} — preserves the zero-transport-
249
+ * in-dry-run invariant (#165 / #379) while honoring #394's AC that the
250
+ * preview shows the full merged shape.
251
+ */
252
+ export declare const DRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDER: "<resolved at send-time by reading current state>";
253
+ /**
254
+ * Build the merged `EmploymentInput` to send for an `UpdateEmployment`
255
+ * mutation by reading the current row and overlaying user-supplied fields.
256
+ *
257
+ * The `talent_profile/graphql` server treats five `EmploymentInput` fields
258
+ * as required non-null on `UpdateEmployment` and rejects the whole
259
+ * variables payload with `"Expected value to not be null"` when they are
260
+ * absent (#394 + #407 for `position` — wire-broke meta-class #392). The
261
+ * five fields are `experienceItems`, `position`, `showViaToptal`,
262
+ * `startDate`, and `skills`. This helper injects them from the current
263
+ * state where the EMPLOYMENT_FRAGMENT surfaces them (`experienceItems`,
264
+ * `position`, `showViaToptal`, `startDate`) and defaults `skills: []`
265
+ * because the fragment does not currently select the read-side `skills`
266
+ * connection. Other fields are left undefined and omitted from the wire
267
+ * payload — the server keeps the existing value for any field absent
268
+ * from the input (the omission-is-preservation half of the merge
269
+ * contract; only the five required-non-null fields force-echo).
270
+ *
271
+ * **Known limitation (#394)**: `skills` defaults to `[]` because the
272
+ * current read fragment does not surface skills. Calling `update()` on a
273
+ * row that has skills will reset them to empty. A follow-up will extend
274
+ * the fragment to read skills and preserve them through the merge; for
275
+ * the test-account this is acceptable, and the bug fix here is for the
276
+ * minimal `{id, role}` repro that previously failed at the wire layer
277
+ * regardless of skills state.
278
+ *
279
+ * Exported so the MCP layer can build the same merged input for the
280
+ * dry-run preview (AC: "Dry-run preview shows the full merged input").
281
+ *
282
+ * @throws `ProfileError("VALIDATION_ERROR")` when `fields` is empty.
283
+ * @throws `ProfileError("VALIDATION_ERROR")` when `current.startDate` is
284
+ * `null` and the caller did not supply a `startDate` override — the
285
+ * wire requires a non-null `startDate` and we have nothing to send.
101
286
  */
102
- export declare function add(token: string, fields: EmploymentFields): Promise<Employment>;
287
+ export declare function buildUpdateEmploymentInput(current: Employment, fields: EmploymentFields): EmploymentFields;
103
288
  /**
104
289
  * Update an existing employment row. Wire format per Pattern 1:
105
290
  * `{ employmentId, employment: EmploymentInput }`.
291
+ *
292
+ * Reads the current row first and merges the five required-non-null
293
+ * fields onto the wire input (see {@link buildUpdateEmploymentInput} for
294
+ * the merge contract and #394 + #407 for the originating wire-broke incidents).
106
295
  */
107
296
  export declare function update(token: string, id: string, fields: EmploymentFields): Promise<Employment>;
108
297
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/services/profile/employment/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/services/profile/employment/index.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (C) 2026 Oleksii PELYKH
3
- import { ProfileError } from "../basic/index.js";
3
+ import { DRY_RUN_PROFILE_ID_PLACEHOLDER, ProfileError } from "../basic/index.js";
4
4
  import { applyUserErrorsAndSuccess, callTalentProfile, ensureNoTopLevelErrors, extractProfileId } from "../shared.js";
5
+ import { buildDryRunPreview } from "../../../transport.js";
5
6
  const EMPLOYMENT_FRAGMENT = `fragment Employment on Employment {
6
7
  id
7
8
  company
@@ -18,6 +19,8 @@ const EMPLOYMENT_FRAGMENT = `fragment Employment on Employment {
18
19
  reportingTo
19
20
  industries { nodes { id name } }
20
21
  primaryGeography { id code name }
22
+ employer { id }
23
+ skills { nodes { id name } }
21
24
  }`;
22
25
  const GET_WORK_EXPERIENCE_QUERY = `query GET_WORK_EXPERIENCE($profileId: ID!) {
23
26
  profile(id: $profileId) {
@@ -92,6 +95,12 @@ function mapEmploymentNode(node) {
92
95
  name: typeof geoRaw.name === "string" ? geoRaw.name : null,
93
96
  }
94
97
  : null;
98
+ const employerRaw = node["employer"];
99
+ const employerId = employerRaw && typeof employerRaw.id === "string" ? employerRaw.id : null;
100
+ const skillsConn = node["skills"];
101
+ const skills = Array.isArray(skillsConn?.nodes)
102
+ ? skillsConn.nodes.flatMap((s) => typeof s.id === "string" && typeof s.name === "string" ? [{ id: s.id, name: s.name }] : [])
103
+ : [];
95
104
  const rawItems = node["experienceItems"];
96
105
  return {
97
106
  id: typeof node["id"] === "string" ? node["id"] : "",
@@ -111,6 +120,8 @@ function mapEmploymentNode(node) {
111
120
  reportingTo: node["reportingTo"] ?? null,
112
121
  industries,
113
122
  primaryGeography,
123
+ employerId,
124
+ skills,
114
125
  };
115
126
  }
116
127
  /**
@@ -151,27 +162,125 @@ export async function show(token, id) {
151
162
  }
152
163
  /**
153
164
  * Create a new employment row. Wire format per Pattern 2: `{ profileId,
154
- * employment: EmploymentInput }`. `company` and `position` are required.
165
+ * employment: EmploymentInput }`.
166
+ *
167
+ * **employerId resolution (#395)**: the live `talent_profile/graphql`
168
+ * server requires `employment.employerId` (the catalog identifier),
169
+ * NOT the free-text `company` string. Pre-#395, `add({company, role,
170
+ * …})` sent only the company string and was rejected with
171
+ * `USER_ERROR: employment add rejected (employerId): You can't leave
172
+ * this empty`. The fix:
173
+ *
174
+ * 1. If `fields.employerId` is supplied → use it verbatim. This is
175
+ * the explicit-bypass path (`--employer-id` on CLI,
176
+ * `employerId` on MCP) — useful for replay scripts, the
177
+ * disambiguation fallback, or known-good ids.
178
+ * 2. Otherwise, fire {@link employerAutocomplete} against
179
+ * `fields.company`. Toptal's autocomplete is fuzzy / prefix-
180
+ * search (typing "Anthropic" returns 10 partial matches), so the
181
+ * practical cardinality is on EXACT NAME MATCH (case-insensitive
182
+ * trim):
183
+ * - exactly 1 exact match → use its id transparently
184
+ * - 2+ exact matches (catalog duplicates — e.g. multiple
185
+ * regional subsidiaries with the same display name) →
186
+ * `VALIDATION_ERROR` listing the duplicates + `--employer-id`
187
+ * nudge
188
+ * - 0 exact, 0 fuzzy → `VALIDATION_ERROR` nudging to the
189
+ * autocomplete CLI command or `--employer-id` bypass
190
+ * - 0 exact, ≥1 fuzzy → `VALIDATION_ERROR` listing the top-N
191
+ * closest candidates with `--employer-id` nudge (the user
192
+ * typed a prefix; surface the catalog's actual names)
193
+ *
194
+ * The static defaults at `experienceItems: []`, `skills: []`,
195
+ * `showViaToptal: true` are retained pre-#395 — they were established
196
+ * empirically via the #344 E2E to satisfy "Expected value to not be
197
+ * null" on those required fields. Reviewing them in this PR would
198
+ * be premature without a fresh live capture; they may be revisited in
199
+ * a follow-up that mirrors the basic.set #393 read-merge pattern for
200
+ * employment.add.
201
+ *
202
+ * **Dry-run path (#395)**: when `options.dryRun === true`, the
203
+ * employer resolution still runs (fires `employersAutocomplete` if
204
+ * `employerId` is absent — except on the #401 custom-workplace path;
205
+ * see **Custom-workplace path (#401)** below) so the preview's
206
+ * `variables.input.employment`
207
+ * carries the resolved `employerId`, matching the wire shape the live
208
+ * mutation would transmit. The `CreateEmployment` mutation transport
209
+ * is NOT invoked. The placeholder
210
+ * {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} stands in for `profileId`
211
+ * (which the apply-path resolves via `extractProfileId`).
212
+ *
213
+ * **Custom-workplace path (#401)**: when `fields.noEmployer === true`,
214
+ * `resolveEmployerId()` is skipped entirely — NO `employersAutocomplete`
215
+ * call in EITHER the apply or dry-run path — and `employerId: null` is
216
+ * sent with the free-text `company`. Mutually exclusive with an explicit
217
+ * `fields.employerId` (→ `VALIDATION_ERROR`). Orthogonal to `noWebsite`.
155
218
  */
156
- export async function add(token, fields) {
219
+ export async function add(token, fields, options = {}) {
157
220
  if (!fields.company || !fields.position) {
158
221
  throw new ProfileError("VALIDATION_ERROR", "employment add requires --company and --role.");
159
222
  }
160
- const profileId = await extractProfileId(token);
161
- const before = await listByProfileId(token, profileId);
162
- const beforeIds = new Set(before.map((e) => e.id));
163
- // The wire requires `experienceItems`, `skills`, and `showViaToptal` to
164
- // be non-null on `CreateEmployment` (live API rejects with
165
- // "Expected value to not be null" otherwise). The synthesized SDL marks
166
- // `CreateEmploymentInput` as `{ _placeholder: String }` these defaults
167
- // were established empirically via the #344 E2E. Callers may still
168
- // override.
223
+ // Custom (non-catalog) workplace path (#401): when `noEmployer` is
224
+ // set, the Toptal "Add as new: <name>" behaviour applies — the wire
225
+ // takes `employerId: null` with the free-text `company` verbatim
226
+ // (there is no `CreateEmployer` mutation anywhere in the schema). It
227
+ // is orthogonal to `noWebsite` (a custom workplace may still have a
228
+ // website) and mutually exclusive with an explicit `employerId` (a
229
+ // catalog id and "not in the catalog" are contradictory).
230
+ if (fields.noEmployer === true && fields.employerId !== undefined && fields.employerId !== "") {
231
+ throw new ProfileError("VALIDATION_ERROR", "employment add: a custom workplace (--no-employer) cannot also pass --employer-id (a catalog id). Use one or the other.");
232
+ }
233
+ // Resolve employerId BEFORE branching on dryRun so the preview's wire
234
+ // shape matches what the live mutation would transmit (#395 explicit
235
+ // AC). The autocomplete query is a read, not a mutation — it fires in
236
+ // both dry-run and apply paths. The custom-workplace path (#401) skips
237
+ // resolution entirely: NO autocomplete network call in EITHER path
238
+ // (apply or dry-run), and `employerId: null` goes on the wire.
239
+ const { noEmployer, ...wireFields } = fields;
240
+ const employerId = noEmployer === true ? null : await resolveEmployerId(token, wireFields);
241
+ // The wire requires several non-null fields on `CreateEmployment`
242
+ // (live API rejects with "Expected value to not be null" / "You can't
243
+ // leave this empty" otherwise). The defaults below were established
244
+ // empirically through E2E iteration — DO NOT add pre-emptive defaults
245
+ // for fields the server hasn't explicitly demanded, since
246
+ // `CreateEmploymentInput` rejects unknown fields with
247
+ // "Field is not defined on EmploymentInput" (e.g. `toptalRelated`,
248
+ // `highlight` are valid on `UpdateEmploymentInput` but NOT on
249
+ // `CreateEmploymentInput`). The request-shaping `noEmployer` signal is
250
+ // destructured out above for the same reason — it is not an
251
+ // `EmploymentInput` field.
252
+ // - `experienceItems`, `skills`, `showViaToptal` — via the #344 E2E
253
+ // - `publicationPermit` — server treats Boolean `false` as blank
254
+ // (USER_ERROR "publicationPermit: You can't leave this empty");
255
+ // default to `true` to satisfy the Rails `.blank?` gate. Mirrors the
256
+ // `buildUpdateEmploymentInput` fallback (`current.publicationPermit
257
+ // ?? true`) so add/update agree on the no-caller-input semantics.
258
+ // Callers may still override.
169
259
  const employment = {
170
260
  experienceItems: [],
171
261
  skills: [],
172
262
  showViaToptal: true,
173
- ...fields,
263
+ publicationPermit: true,
264
+ ...wireFields,
265
+ employerId,
174
266
  };
267
+ if (options.dryRun === true) {
268
+ return {
269
+ kind: "preview",
270
+ preview: buildDryRunPreview({
271
+ surface: "talent-profile",
272
+ authToken: token,
273
+ body: {
274
+ operationName: "CreateEmployment",
275
+ query: CREATE_EMPLOYMENT_MUTATION,
276
+ variables: { input: { profileId: DRY_RUN_PROFILE_ID_PLACEHOLDER, employment } },
277
+ },
278
+ }),
279
+ };
280
+ }
281
+ const profileId = await extractProfileId(token);
282
+ const before = await listByProfileId(token, profileId);
283
+ const beforeIds = new Set(before.map((e) => e.id));
175
284
  const res = await callTalentProfile(token, "CreateEmployment", CREATE_EMPLOYMENT_MUTATION, { input: { profileId, employment } }, "employment add");
176
285
  const payload = unwrapMutation(res, "createEmployment", "employment add");
177
286
  const after = payload.profile?.employments.nodes.filter((n) => n !== null).map(mapEmploymentNode) ??
@@ -180,17 +289,204 @@ export async function add(token, fields) {
180
289
  if (!created) {
181
290
  throw new ProfileError("UNKNOWN", "employment add returned success but no new row was found in the response.");
182
291
  }
183
- return created;
292
+ return { kind: "created", result: created };
293
+ }
294
+ /**
295
+ * Resolve `fields.employerId` for the create-employment flow (#395).
296
+ *
297
+ * - Explicit `fields.employerId` → returned verbatim (bypass).
298
+ * - Otherwise call {@link employerAutocomplete}; Toptal's autocomplete
299
+ * is fuzzy / prefix-search, so the practical cardinality is on
300
+ * **exact name match** (case-insensitive, trimmed):
301
+ * - 1 exact match → transparent use
302
+ * - 0 exact, 0 fuzzy → "No employer matched" nudge
303
+ * - 0 exact, ≥1 fuzzy → "No exact match; closest candidates"
304
+ * listing + `--employer-id` nudge (the user typed a prefix /
305
+ * substring; we surface the catalog's actual names so they can
306
+ * pick an id)
307
+ * - 2+ exact → disambiguation listing of the duplicates (the
308
+ * catalog has multiple records with the same display name —
309
+ * common for companies with city subsidiaries; the user must
310
+ * pick one)
311
+ *
312
+ * Errors all surface as `VALIDATION_ERROR` with actionable recovery
313
+ * text so the CLI / MCP layer can render them as user-facing
314
+ * messages without further classification.
315
+ */
316
+ async function resolveEmployerId(token, fields) {
317
+ if (fields.employerId !== undefined && fields.employerId !== "") {
318
+ return fields.employerId;
319
+ }
320
+ // `fields.company` is asserted non-empty by the caller (the add()
321
+ // pre-flight rejects an empty company / position).
322
+ const company = fields.company ?? "";
323
+ const matches = await employerAutocomplete(token, company);
324
+ const norm = company.trim().toLowerCase();
325
+ const exact = matches.filter((m) => m.name.trim().toLowerCase() === norm);
326
+ if (exact.length === 1) {
327
+ const only = exact[0];
328
+ if (only === undefined) {
329
+ // Defensive: exact.length === 1 but indexed read is undefined —
330
+ // a TypeScript noUncheckedIndexedAccess guard. Unreachable at
331
+ // runtime.
332
+ throw new ProfileError("UNKNOWN", "employer-autocomplete returned 1 exact match but indexing it yielded undefined.");
333
+ }
334
+ return only.id;
335
+ }
336
+ if (exact.length >= 2) {
337
+ // Multiple catalog records share the user-supplied exact name
338
+ // (common for global companies with regional subsidiaries listed
339
+ // separately). Surface only the exact-name duplicates — the fuzzy
340
+ // siblings would just add noise.
341
+ const list = exact.map(formatCandidate).join("\n");
342
+ throw new ProfileError("VALIDATION_ERROR", `Multiple employers matched "${company}" exactly (${exact.length.toString()} duplicates in the catalog):\n` +
343
+ `${list}\n` +
344
+ `Pass \`--employer-id <id>\` to disambiguate.`);
345
+ }
346
+ // exact.length === 0
347
+ if (matches.length === 0) {
348
+ throw new ProfileError("VALIDATION_ERROR", `No employer matched "${company}". Use ` +
349
+ `\`ttctl profile employment employer-autocomplete <query>\` to search the catalog, ` +
350
+ `or pass \`--employer-id <id>\` to bypass autocomplete.`);
351
+ }
352
+ // 0 exact, ≥1 fuzzy. Surface the catalog's actual names so the user
353
+ // can refine (or pick an id directly).
354
+ const top = matches.slice(0, 5);
355
+ const list = top.map(formatCandidate).join("\n");
356
+ throw new ProfileError("VALIDATION_ERROR", `No exact match for "${company}" in the employer catalog ` +
357
+ `(${matches.length.toString()} fuzzy match${matches.length === 1 ? "" : "es"}; showing top ${top.length.toString()}):\n` +
358
+ `${list}\n` +
359
+ `Refine the company string to the exact catalog name, or pass \`--employer-id <id>\` to bypass autocomplete.`);
360
+ }
361
+ function formatCandidate(m) {
362
+ const loc = [m.city, m.country].filter((v) => v !== null && v !== "").join(", ");
363
+ return ` - ${m.id} ${m.name}${loc ? ` (${loc})` : ""}`;
364
+ }
365
+ /**
366
+ * Placeholder string substituted into a dry-run `UpdateEmployment`
367
+ * preview's variables payload for fields that the apply-path resolves by
368
+ * reading the current row (`experienceItems`, `position`, `skills`,
369
+ * `showViaToptal`, `startDate` — the five required-non-null fields
370
+ * injected by the read-current+merge logic, #394 + #407 for `position`).
371
+ * Surfaced verbatim so MCP consumers can
372
+ * see the structural shape of what will be sent without TTCtl having
373
+ * fired the read transport. Same posture as `basic.set`'s
374
+ * {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} — preserves the zero-transport-
375
+ * in-dry-run invariant (#165 / #379) while honoring #394's AC that the
376
+ * preview shows the full merged shape.
377
+ */
378
+ export const DRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDER = "<resolved at send-time by reading current state>";
379
+ /**
380
+ * Build the merged `EmploymentInput` to send for an `UpdateEmployment`
381
+ * mutation by reading the current row and overlaying user-supplied fields.
382
+ *
383
+ * The `talent_profile/graphql` server treats five `EmploymentInput` fields
384
+ * as required non-null on `UpdateEmployment` and rejects the whole
385
+ * variables payload with `"Expected value to not be null"` when they are
386
+ * absent (#394 + #407 for `position` — wire-broke meta-class #392). The
387
+ * five fields are `experienceItems`, `position`, `showViaToptal`,
388
+ * `startDate`, and `skills`. This helper injects them from the current
389
+ * state where the EMPLOYMENT_FRAGMENT surfaces them (`experienceItems`,
390
+ * `position`, `showViaToptal`, `startDate`) and defaults `skills: []`
391
+ * because the fragment does not currently select the read-side `skills`
392
+ * connection. Other fields are left undefined and omitted from the wire
393
+ * payload — the server keeps the existing value for any field absent
394
+ * from the input (the omission-is-preservation half of the merge
395
+ * contract; only the five required-non-null fields force-echo).
396
+ *
397
+ * **Known limitation (#394)**: `skills` defaults to `[]` because the
398
+ * current read fragment does not surface skills. Calling `update()` on a
399
+ * row that has skills will reset them to empty. A follow-up will extend
400
+ * the fragment to read skills and preserve them through the merge; for
401
+ * the test-account this is acceptable, and the bug fix here is for the
402
+ * minimal `{id, role}` repro that previously failed at the wire layer
403
+ * regardless of skills state.
404
+ *
405
+ * Exported so the MCP layer can build the same merged input for the
406
+ * dry-run preview (AC: "Dry-run preview shows the full merged input").
407
+ *
408
+ * @throws `ProfileError("VALIDATION_ERROR")` when `fields` is empty.
409
+ * @throws `ProfileError("VALIDATION_ERROR")` when `current.startDate` is
410
+ * `null` and the caller did not supply a `startDate` override — the
411
+ * wire requires a non-null `startDate` and we have nothing to send.
412
+ */
413
+ export function buildUpdateEmploymentInput(current, fields) {
414
+ if (Object.keys(fields).length === 0) {
415
+ throw new ProfileError("VALIDATION_ERROR", "employment update requires at least one field flag.");
416
+ }
417
+ const startDate = fields.startDate ?? current.startDate;
418
+ if (startDate === null) {
419
+ throw new ProfileError("VALIDATION_ERROR", `Cannot update employment "${current.id}": startDate is required and current value is null. Supply --from to set a year.`);
420
+ }
421
+ // Server-side Rails `.blank?` gates (USER_ERROR "You can't leave this
422
+ // empty") — surfaced by the #394 live capture (2026-05-19): when the
423
+ // caller omits these, the wire layer accepts the partial input but
424
+ // the Rails apply path rejects it. Inject from the current row so
425
+ // user-supplied fields can still override. Optional pass-throughs
426
+ // (employerId / primaryGeographyId / reportingTo) are only set when
427
+ // the current row has a non-null value — sending an explicit null
428
+ // would change the row's state, which would defeat "merge".
429
+ //
430
+ // #401 WORM limitation (2026-05-19 live capture): the UpdateEmployment
431
+ // wire treats BOTH absence AND explicit null of `employerId` as Rails
432
+ // `.blank?` and rejects with "employerId: You can't leave this
433
+ // empty". Custom workplaces (CreateEmployment with `employerId: null`)
434
+ // therefore CANNOT be updated via this surface — they are write-once-
435
+ // read-many on Toptal. The "omit when null" branch below reflects
436
+ // this honestly: there is no employerId payload we can send that
437
+ // satisfies the wire for a null-employerId row. update() on such a
438
+ // row will surface the USER_ERROR verbatim. See
439
+ // `research/notes/15-employment-custom-workplace-worm.md` and #401.
440
+ const merged = {
441
+ // Wire-required non-null (GraphQL `Expected value to not be null`):
442
+ experienceItems: current.experienceItems ?? [],
443
+ // #407 — same wire-required non-null class: server rejects with
444
+ // `Expected value to not be null` for `employment.position` on any
445
+ // partial update that omits it. EMPLOYMENT_FRAGMENT selects `position`
446
+ // so `current.position` is always available to thread through.
447
+ position: current.position,
448
+ // Preserve current row's skills through the merge — server rejects
449
+ // `skills: []` with "is too short (minimum is 1 character)" on
450
+ // update (#394 live-capture finding 2026-05-19). The EMPLOYMENT_FRAGMENT
451
+ // now selects `skills { nodes { id name } }` so `current.skills` is
452
+ // populated; pre-#394 it was always `[]` and update() defaulted to
453
+ // empty, which is what the live wire was rejecting.
454
+ skills: current.skills,
455
+ showViaToptal: current.showViaToptal,
456
+ startDate,
457
+ // Rails `.blank?` gates:
458
+ company: current.company,
459
+ publicationPermit: current.publicationPermit ?? true,
460
+ // industryIds: catalog refs the wire requires present and non-empty
461
+ // on the apply path.
462
+ industryIds: current.industries.map((i) => i.id),
463
+ };
464
+ if (current.employerId !== null) {
465
+ merged.employerId = current.employerId;
466
+ }
467
+ if (current.primaryGeography !== null) {
468
+ merged.primaryGeographyId = current.primaryGeography.id;
469
+ }
470
+ if (current.reportingTo !== null) {
471
+ merged.reportingTo = current.reportingTo;
472
+ }
473
+ return { ...merged, ...fields };
184
474
  }
185
475
  /**
186
476
  * Update an existing employment row. Wire format per Pattern 1:
187
477
  * `{ employmentId, employment: EmploymentInput }`.
478
+ *
479
+ * Reads the current row first and merges the five required-non-null
480
+ * fields onto the wire input (see {@link buildUpdateEmploymentInput} for
481
+ * the merge contract and #394 + #407 for the originating wire-broke incidents).
188
482
  */
189
483
  export async function update(token, id, fields) {
190
484
  if (Object.keys(fields).length === 0) {
191
485
  throw new ProfileError("VALIDATION_ERROR", "employment update requires at least one field flag.");
192
486
  }
193
- const res = await callTalentProfile(token, "UpdateEmployment", UPDATE_EMPLOYMENT_MUTATION, { input: { employmentId: id, employment: fields } }, "employment update");
487
+ const current = await show(token, id);
488
+ const employment = buildUpdateEmploymentInput(current, fields);
489
+ const res = await callTalentProfile(token, "UpdateEmployment", UPDATE_EMPLOYMENT_MUTATION, { input: { employmentId: id, employment } }, "employment update");
194
490
  const payload = unwrapMutation(res, "updateEmployment", "employment update");
195
491
  const updated = payload.profile?.employments.nodes
196
492
  .filter((n) => n !== null)