Skip to content

v0.1.0-rc.12

Pre-release
Pre-release

Choose a tag to compare

@alexey-pelykh alexey-pelykh released this 26 May 20:32
· 120 commits to main since this release

Added

  • profile.employment.skills.add / profile.employment.skills.remove:
    per-skill additive wrappers over the full-replace UpdateEmployment
    (#614).
    Bulk profile uplift across many employments (the maintainer
    carries 13 rows, the largest with 100+ skills) previously forced callers
    to re-implement the read-merge-write bookkeeping ttctl already performs
    internally — one merge script per additive op. Both leaves wrap
    update()'s buildUpdateEmploymentInput merge path: read the current
    row via show(), compute the merged (dedupe by id, preserve current
    order) or filtered skills array, then fire one UpdateEmployment
    mutation per row. Discriminated outcome (updated | noop | preview):
    add returns noop when every supplied id is already linked (no wire
    fire); remove returns noop when no supplied id matches the row AND
    refuses with VALIDATION_ERROR when the filtered set would be empty
    (Toptal server rejects skills: [], naming profile.employment.remove
    as the row-level alternative); caller-supplied duplicates dedupe against
    each other and against current state. Core: profile.employment.skills
    namespace exporting add(token, employmentId, skillIds, options) and
    remove(...). CLI: ttctl profile employment skills {add,remove} <id> --skill-id <id> (repeatable). MCP: ttctl_profile_employment_skills_{add,remove}
    using the existing DRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDER for zero-wire
    preview. Wire-shape disposition: Schema/contract rule triggered
    (path packages/core/src/services/profile/employment/**) but introduces
    no new GraphQL ops — both wrappers route through the existing
    UpdateEmployment mutation; Track 1 (existing snapshot)
    UpdateEmployment snapshot owned by
    46-profile-employment-update-merge.e2e.test.ts. Validated live
    (TTCTL_E2E=1) via packages/e2e/src/79-profile-employment-skills.e2e.test.ts
    (add → idempotent re-add → remove → idempotent re-remove →
    refusal-on-empty).
  • profile.skills.remove-connection: per-edge unlink of one
    ProfileSkillSet → entity link via the removeProfileSkillSetConnection
    mutation (#463).
    Sibling to profile.skills.add-connection (#462 /
    rc.11); removes one connection without cascading to the whole skill-set.
    Wire input is CAPTURED
    (research/captures/web/inputs/RemoveProfileSkillSetConnectionInput.json):
    { skillSetId, connectionId } — two fields, no connectionType (the
    server discriminates the target from the Relay node id's base64 type
    prefix; this refutes the prior Pattern-6 inference in
    research/notes/10). Same ADR-009 profile-capability consent domain
    (profileCapabilityConsentIssued: true). Core:
    profile.skills.removeConnection(token, fields, consent, options)
    returns { skillSetId, connectionsCount, connectionIds, notice } with
    the just-unlinked id absent from connectionIds; consent gate
    (CONSENT_REQUIRED fires BEFORE dry-run); USER_ERROR mapping for
    success: false / errors[]; UNKNOWN for null payload;
    AuthRevokedError / Cf403Error propagated. CLI: ttctl profile skills remove-connection --skill-set-id <id> --connection-id <id> --consent-profile-capability (no --connection-type flag — locked at the
    unit level by expect(flags).not.toContain('--connection-type') to
    prevent regression to the 3-field wire shape). MCP:
    ttctl_profile_skills_remove_connection with destructiveHint: true
    and profileCapabilityConsentIssued: z.literal(true). Wire-shape
    disposition: Schema/contract rule triggered (new GraphQL op
    removeProfileSkillSetConnection under
    packages/core/src/services/profile/**, in
    TALENT_PROFILE_KNOWN_UNTRUSTED_OPS); Track 1 — snapshot file
    packages/e2e/src/wire-snapshots/removeProfileSkillSetConnection.snapshot.json
    is intentionally absent at PR-merge time and captured operator-driven
    via TTCTL_E2E_REMOVE_SKILL_CONNECTION=…:… TTCTL_UPDATE_WIRE_SNAPSHOTS=1
    post-merge (mirrors sibling #462). Validated live (TTCTL_E2E=1) via
    packages/e2e/src/80-profile-skills-remove-connection.e2e.test.ts
    (always-on dry-run + consent-missing paths; gated DESTRUCTIVE positive
    path requires a populated skill-set with a linked target).

Changed

  • profile.skills.add-connection: trim wire-extra connectionType and
    add a Relay-prefix cross-check (#626).
    The capture
    research/captures/web/inputs/AddProfileSkillSetConnectionInput.json
    sends only { skillSetId, connectionId } (two fields) — the server
    discriminates the target from the Relay node id's base64 type segment.
    ttctl's previous send shape (three fields including connectionType)
    was Pattern-6 inferred at #462; this PR aligns the wire to the captured
    shape and keeps --connection-type at the CLI / MCP surface as a
    client-side UX guard. New private inferConnectionTypeFromId helper +
    RELAY_PREFIX_TO_CONNECTION_TYPE map cross-check the declared
    connectionType against the connectionId Relay prefix; both an
    unrecognized prefix and a prefix-vs-declared-type mismatch now throw
    VALIDATION_ERROR BEFORE any wire call. Wire-shape disposition:
    Schema/contract rule triggered (existing op, wire input shape
    modified; touches packages/core/src/services/profile/**); Track 1
    (addProfileSkillSetConnection in TALENT_PROFILE_KNOWN_UNTRUSTED_OPS;
    the response snapshot at
    packages/e2e/src/wire-snapshots/addProfileSkillSetConnection.snapshot.json
    is unchanged, and the request-side trim is asserted at the unit-test
    level via .toEqual on the dry-run preview's variables.input).
    BC: callers passing an unrecognized Relay prefix or a
    prefix-vs-declared-type mismatch now surface VALIDATION_ERROR at the
    service boundary instead of a GRAPHQL_ERROR from the live wire —
    same end-state (operation refused), better diagnostics. Suite-wide
    sweep dropped stale "Pattern-6 / INFERRED wire shape" claims from the
    CLI --help, service / tool JSDoc, the 78-profile-skills-add-connection.e2e.test.ts
    header, and the addProfileSkillSetConnection row rationale in
    docs/wire-validation-routing.md.

Fixed

  • profile.specializations.show exposes operations.apply.callable as
    a string, not a boolean (#637).
    The synthesized schema declares
    Operation.callable: String! (empirical value "ENABLED") and the
    codegen already types every other site correctly; only the
    specializations service mistyped it as boolean and projected with
    ?? false — the wire was always returning a string ("ENABLED"), and
    TTCTL_E2E=1 pnpm test:e2e src/66-profile-specializations-show.e2e.test.ts
    surfaced the drift via typeof apply["callable"] returning "string"
    not "boolean". Scope: SpecializationApplyOperation.callable
    retyped string; projection defaults to "" on a missing wire field;
    pretty / table formatters drop the dead .toString() calls; CLI text
    formatter renders (unset) for the empty default; MCP tool docs
    corrected. BC: the JSON / YAML output for
    ttctl profile specializations show now emits a string value (e.g.
    "ENABLED") instead of a boolean — jq / yq consumers comparing
    with == or === need to compare against the string. Same posture
    for callers of profile.specializations.show() programmatically.
  • profile.education.update no longer nulls omitted writable fields under
    the full-replacement contract, and add/update map ttctl-surface
    institution to wire title (the only school-name slot on
    EducationInput) (#612).
    Same posture as UpdateCertification #605
    and UpdateBasicInfo #604: UpdateEducation treats EducationInput as
    a full replacement — pre-fix, calling ttctl profile education update <id> --highlight true would null every other field server-side. Pre-fix,
    add() and update() also sent education.institution: <value> to a
    wire input that has no institution slot (capture
    research/captures/web/inputs/UpdateEducationInput.json) — the live API
    rejected with GRAPHQL_ERROR, blocking BOTH adding and updating
    education rows from ttctl. Scope: core update() now reads the
    current row via show() then merges through the exported
    buildUpdateEducationInput(current, fields) helper (mirror of
    buildUpdateCertificationInput); add() builds the wire input via
    toEducationWireInput(fields) and defaults skills: [] (the wire
    requires non-null, same .blank? posture as the cert sibling).
    DRY_RUN_EDUCATION_FIELD_PLACEHOLDER is exported for the MCP layer;
    the MCP _update dry-run preview now surfaces the placeholder for
    every unconditional-echo field. BC: removed the --title flag from
    ttctl profile education add/update and the title input on the
    MCP ttctl_profile_education_add/_update tools — the wire title
    slot is owned by institution (school name), and the read-side
    Education.title is server-populated, not user-controlled. Any caller
    passing --title was previously overwriting the school name; no
    preservation path exists because the previous semantics were
    data-corrupting.
  • profile.skills experience is years on the wire, not months (#627).
    The MCP ttctl_profile_skills_update.experience docstring claimed
    "months"; the CLI --experience flag, parseExperience parser,
    validation messages, and pretty-print rendering all carried the same
    wrong-unit assumption (with parseExperience ACTIVELY multiplying Ny
    inputs by 12). The wire capture at
    research/captures/web/inputs/UpdateProfileSkillSetExperienceInput.json
    annotates Int (years; 1-20+), and several talents' public profiles
    rendered nonsense values (e.g., "60 years" for a skill the caller
    intended as 5 years). Scope: corrected every docstring, validation
    message, help text, parser, format helper, service jsdoc, test fixture,
    and snapshot in the skills sub-domain. parseExperience no longer
    multiplies by 12 — 5y now returns 5; the Nm (months) shorthand
    is rejected outright (was never working semantics). Added defensive
    .max(70) Zod bound on both ttctl_profile_skills_add.experience and
    ttctl_profile_skills_update.experience so corruption attempts
    surface at the MCP boundary instead of silently landing on the wire.
    BC note: any caller passing --experience 5y previously sent 60
    (= 60 years on the wire — already broken); now sends 5 correctly. No
    preservation path exists for the previous semantics because the
    previous semantics were data-corrupting.