@company-semantics/contracts 96.0.0 → 98.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "96.0.0",
3
+ "version": "98.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'cb45037e11c4' as const;
3
- export const SPEC_HASH_FULL = 'cb45037e11c4c1be2eb1ef3426333f09fd659b0b60f3c8877949f1309b428ced' as const;
2
+ export const SPEC_HASH = '51e9ee445972' as const;
3
+ export const SPEC_HASH_FULL = '51e9ee445972ebf381cd85489343494ecdd81453574bbec8696a038ef4fd327f' as const;
@@ -1712,7 +1712,7 @@ export interface paths {
1712
1712
  };
1713
1713
  /**
1714
1714
  * Read one analysis workflow’s generic lifecycle envelope
1715
- * @description The domain-agnostic projection a client polls: identity, registered type, activity-free lifecycle state, start and completion times, the opaque domain progress and result payloads (null until the domain adapter supplies them), a closed failure class on a failed workflow, the frozen typed outcome of the current execution epoch once it settles (null until then), and the capabilities the surface may offer — canStop from the generic layer, canContinue as the domain declares it. Org-scoped: a workflow in another workspace answers 404.
1715
+ * @description The domain-agnostic projection a client polls: identity, registered type, activity-free lifecycle state, start and completion times, the opaque domain progress and result payloads (null until the domain adapter supplies them), a closed failure class on a failed workflow, the frozen typed outcome of the current execution epoch once it settles (null until then), the execution affordances offered now (the contracts offer table over state, the type’s execution support and the frozen outcome, filtered by the viewer’s policy), the workflow it was relaunched from, and the current execution epoch’s start and completion times. Org-scoped: a workflow in another workspace answers 404.
1716
1716
  */
1717
1717
  get: operations["getAnalysisWorkflow"];
1718
1718
  put?: never;
@@ -4928,7 +4928,7 @@ export interface paths {
4928
4928
  };
4929
4929
  /**
4930
4930
  * Get the price list
4931
- * @description The seat price, the hosted surcharge and BYO fee, the active price-book version, and every priced model at its reference rates beside the hosted rates derived from them.
4931
+ * @description The seat price, the hosted surcharge and BYO fee, a price-book version (the active one, or the one named by ?version), and every priced model as its card: provenance, aliases, and each processing mode and prompt tier at its reference rates beside the hosted rates derived from them.
4932
4932
  */
4933
4933
  get: operations["getBillingPriceList"];
4934
4934
  put?: never;
@@ -4939,6 +4939,26 @@ export interface paths {
4939
4939
  patch?: never;
4940
4940
  trace?: never;
4941
4941
  };
4942
+ "/api/billing/price-book-versions": {
4943
+ parameters: {
4944
+ query?: never;
4945
+ header?: never;
4946
+ path?: never;
4947
+ cookie?: never;
4948
+ };
4949
+ /**
4950
+ * List price-book versions
4951
+ * @description Every published price-book version, newest first, with its provenance and the one calls are priced against now marked active.
4952
+ */
4953
+ get: operations["getBillingPriceBookVersions"];
4954
+ put?: never;
4955
+ post?: never;
4956
+ delete?: never;
4957
+ options?: never;
4958
+ head?: never;
4959
+ patch?: never;
4960
+ trace?: never;
4961
+ };
4942
4962
  "/api/source-sync/discovery/runs": {
4943
4963
  parameters: {
4944
4964
  query?: never;
@@ -5632,7 +5652,16 @@ export interface components {
5632
5652
  presentationGroupId: string | null;
5633
5653
  /** @enum {string} */
5634
5654
  turnKind: "conversation" | "workflow_launch" | "workflow_settlement" | "input_request_launch" | "proactive" | "refusal";
5635
- myFeedback: ("up" | "down") | null;
5655
+ alternatives: {
5656
+ position: number;
5657
+ total: number;
5658
+ ids: string[];
5659
+ } | null;
5660
+ myFeedback: {
5661
+ /** @enum {string} */
5662
+ rating: "up" | "down";
5663
+ reason: ("incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other") | null;
5664
+ } | null;
5636
5665
  }[];
5637
5666
  };
5638
5667
  SuccessResponse: {
@@ -7736,10 +7765,38 @@ export interface components {
7736
7765
  })[];
7737
7766
  }) | null;
7738
7767
  error?: string;
7739
- capabilities: {
7740
- canStop: boolean;
7741
- canContinue: boolean;
7742
- };
7768
+ executionAffordances: ({
7769
+ /** @constant */
7770
+ id: "workflow.stop";
7771
+ args: {
7772
+ /** Format: uuid */
7773
+ workflowId: string;
7774
+ };
7775
+ } | {
7776
+ /** @constant */
7777
+ id: "workflow.continue";
7778
+ args: {
7779
+ /** Format: uuid */
7780
+ workflowId: string;
7781
+ };
7782
+ } | {
7783
+ /** @constant */
7784
+ id: "workflow.retry";
7785
+ args: {
7786
+ /** Format: uuid */
7787
+ workflowId: string;
7788
+ };
7789
+ } | {
7790
+ /** @constant */
7791
+ id: "workflow.runAgain";
7792
+ args: {
7793
+ /** Format: uuid */
7794
+ workflowId: string;
7795
+ };
7796
+ })[];
7797
+ relaunchedFrom: string | null;
7798
+ epochStartedAt: string | null;
7799
+ epochCompletedAt: string | null;
7743
7800
  };
7744
7801
  StopAnalysisWorkflowResponse: {
7745
7802
  /** @constant */
@@ -10425,6 +10482,16 @@ export interface components {
10425
10482
  }[];
10426
10483
  }[];
10427
10484
  };
10485
+ PriceBookVersionList: {
10486
+ versions: {
10487
+ versionLabel: string;
10488
+ /** Format: date-time */
10489
+ effectiveAt: string;
10490
+ source: string;
10491
+ notes: string | null;
10492
+ active: boolean;
10493
+ }[];
10494
+ };
10428
10495
  SourceSyncDiscoveryRunResponse: {
10429
10496
  /** Format: uuid */
10430
10497
  runId: string;
@@ -20796,7 +20863,9 @@ export interface operations {
20796
20863
  };
20797
20864
  getBillingPriceList: {
20798
20865
  parameters: {
20799
- query?: never;
20866
+ query?: {
20867
+ version?: string;
20868
+ };
20800
20869
  header?: never;
20801
20870
  path?: never;
20802
20871
  cookie?: never;
@@ -20812,6 +20881,13 @@ export interface operations {
20812
20881
  "application/json": components["schemas"]["PriceList"];
20813
20882
  };
20814
20883
  };
20884
+ /** @description No price-book version carries that label */
20885
+ 404: {
20886
+ headers: {
20887
+ [name: string]: unknown;
20888
+ };
20889
+ content?: never;
20890
+ };
20815
20891
  /** @description No price book is loaded */
20816
20892
  503: {
20817
20893
  headers: {
@@ -20821,6 +20897,26 @@ export interface operations {
20821
20897
  };
20822
20898
  };
20823
20899
  };
20900
+ getBillingPriceBookVersions: {
20901
+ parameters: {
20902
+ query?: never;
20903
+ header?: never;
20904
+ path?: never;
20905
+ cookie?: never;
20906
+ };
20907
+ requestBody?: never;
20908
+ responses: {
20909
+ /** @description The price book version history */
20910
+ 200: {
20911
+ headers: {
20912
+ [name: string]: unknown;
20913
+ };
20914
+ content: {
20915
+ "application/json": components["schemas"]["PriceBookVersionList"];
20916
+ };
20917
+ };
20918
+ };
20919
+ };
20824
20920
  startSourceDiscoveryRun: {
20825
20921
  parameters: {
20826
20922
  query?: never;
@@ -59,16 +59,30 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
59
59
 
60
60
  One turn is one PRESENTATION GROUP whose action row appears once, when the turn settles (control doctrine `chat-action-system`; ADR slug `chat-turn-identity-and-message-actions`).
61
61
 
62
- - `ChatMessage.presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED: null for no group, a kind for every row, null for no rating. They shipped in a MAJOR so no consumer holds a second opinion about a missing key
62
+ - `ChatMessage.presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED: null for no group, a kind for every row, null for no rating (otherwise `{ rating, reason }` — see "Versions and feedback"). They shipped in a MAJOR so no consumer holds a second opinion about a missing key
63
63
  - `TURN_KINDS` (`turn-kind.ts`) is CLOSED and append-only. The server derives the kind from what persisted the row; a client never infers it from content. A turn kind is not a principal kind and never changes `author`
64
64
  - `myFeedback` is the READER's own rating, per-reader like `myRole`
65
65
  - `ChatStreamMessageMetadataSchema` (`stream-metadata.ts`) rides the AI SDK `start` chunk. The live message adopts `messageId`, so a streaming turn already IS the persisted row, already grouped; every field means what the same-named `ChatMessage` field means
66
66
  - `messageActionsFor` (`message-actions.ts`) is the ONE eligibility function: the app renders the action row from it and the backend enforces the same function (`isMessageActionAllowed`) on every regenerate, edit and feedback request. Hiding a button is never the boundary
67
- - `messageActionsFor` is PURE and TOTAL. An unsettled turn offers nothing; input that does not parse offers nothing — unknown is never permissive. Regenerate is offered only on the latest settled `conversation` answer, edit only on the reader's own latest `conversation` prompt
67
+ - `messageActionsFor` is PURE and TOTAL. An unsettled turn offers nothing; input that does not parse offers nothing — unknown is never permissive. Regenerate is offered on ANY settled `conversation` answer and edit on ANY settled `conversation` prompt the reader authored — not only the latest: both add a sibling version (a branch) and never delete. `isLatestOnActiveBranch` stays on the context but gates nothing. Launch, settlement, input-request, proactive and refusal turns never offer regenerate
68
68
  - Its per-kind table is `satisfies Record<TurnKind, …>`, so a new kind fails to compile until its eligibility is decided
69
69
  - Execution actions (cancel, continue, retry, run again) are NOT message actions and are not here — they reach a person as affordances
70
70
  - Load order: `turn-kind.ts` is a LEAF (zod only). `schemas.ts`, `stream-metadata.ts` and `message-actions.ts` import it; the latter two never import `schemas.ts`, and `index.ts` re-exports all three directly
71
71
 
72
+ ## Versions and feedback
73
+
74
+ Regenerating an answer or editing a prompt ADDS A SIBLING VERSION of the turn — a branch in a message tree with an active leaf — and never deletes anything; a reader's rating of a turn carries a CLOSED reason (ADR slug `response-versions-and-feedback-reasons`).
75
+
76
+ - `ChatMessage.alternatives` is REQUIRED and NULLABLE: null means the turn has no siblings. Otherwise `{ position, total, ids }` — `position` is 1-based, `ids` lists every sibling (this one included) ORDERED BY CREATION, and the schema refuses `ids.length !== total` or `position > total`, so a client never reconciles fields that disagree
77
+ - The transcript carries the ACTIVE branch only. Other versions are reachable by id, never inlined
78
+ - `ChatBranchSelectRequestSchema` (`{ messageId }`) is the body that makes one sibling the active branch. Naming an id is a lookup, not authority — the server re-checks it is a version of a turn in this chat
79
+ - `FEEDBACK_RATINGS` is `up` / `down`. `FEEDBACK_REASONS` (`feedback.ts`) is CLOSED and append-only: a reason is a category the product can count, and free text belongs in `comment` (at most `FEEDBACK_COMMENT_MAX` = 2000 characters), never in a reason
80
+ - `PutChatMessageFeedbackRequestSchema` is the body of `putChatMessageFeedback`, named after the generated operation's component so the backend can replace its route-local schema of the same name. A reason or a comment rides only a `down` rating; the schema refuses either on `up`. It is a deliberate exception to "request schemas live in backend": the closed reason list is shared vocabulary the app's picker and the backend's check must agree on
81
+ - `ChatMessage.myFeedback` is `{ rating, reason } | null` — the READER's own rating; `reason` is null on an up rating and on a down rating given without one. The comment is write-only and never projected back onto the transcript
82
+ - Feedback MEASURES a turn. It is NOT APPROVAL of anything the turn proposed, and never authorizes, confirms or executes
83
+ - Load order: `feedback.ts` is a LEAF (zod only). `schemas.ts` imports it, it never imports `schemas.ts`, and `index.ts` re-exports it directly
84
+ - `openapi/backend.yaml` deliberately lags (contracts-first): its `ChatMessage` has no `alternatives`, its `myFeedback` is a bare rating, and its feedback `reason` is free text. It catches up when the backend regenerates its spec
85
+
72
86
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
73
87
 
74
88
  ## Public API
@@ -83,6 +97,8 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
83
97
  - `CHAT_PAGE_VIEWS` — The app route parser's fifteen `type` values, projected onto the wire.
84
98
  - `CHAT_RUNTIME_PROFILES` — Ordered list of available profiles for UI rendering.
85
99
  - `CONVERSATION_SUBJECT_KINDS` — The kinds of subject a chat can continue.
100
+ - `ChatBranchSelectRequest` _(type)_ — Request body that makes one sibling version the active branch.
101
+ - `ChatBranchSelectRequestSchema` — Request body that makes one version the active branch (response-versions-and-feedback-reasons).
86
102
  - `ChatByInteractionResponse` _(type)_ — Response for GET /api/chats/by-interaction/:interactionId
87
103
  - `ChatByInteractionResponseSchema` — Response for GET /api/chats/by-interaction/:interactionId
88
104
  - `ChatChangedField` _(type)_ — Fields that can change on a chat, used in changed array.
@@ -111,8 +127,12 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
111
127
  - `ChatMentionableResponse` _(type)_ — Response for the chat composer's mentionable-candidate endpoint.
112
128
  - `ChatMentionableResponseSchema` — Who the chat composer may offer as a mention target.
113
129
  - `ChatMessage` _(type)_ — Individual message in a chat response (GET /api/chats/:id).
130
+ - `ChatMessageAlternatives` _(type)_ — The sibling versions of one turn — 1-based `position`, `total`, and every sibling id ordered by creation…
131
+ - `ChatMessageAlternativesSchema` — The VERSIONS of one turn (response-versions-and-feedback-reasons, ).
114
132
  - `ChatMessageMention` _(type)_ — One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16 code-unit range into `content`, and a…
115
133
  - `ChatMessageMentionSchema` — ONE mention on a chat turn — field-for-field `CommentMentionSchema` (`../comments/schemas`), and deliberately…
134
+ - `ChatMessageMyFeedback` _(type)_
135
+ - `ChatMessageMyFeedbackSchema` — The reader's own rating as `ChatMessage.myFeedback` carries it.
116
136
  - `ChatMessageSchema`
117
137
  - `ChatOrigin` _(type)_ — How a chat was born: `user` or `proactive` (the system spoke first).
118
138
  - `ChatOriginSchema` — How a chat was born: by the user, or by the system speaking first (a proactive occurrence projected onto the…
@@ -143,6 +163,13 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
143
163
  - `CreateChatResponse` _(type)_ — Response for POST /api/chats
144
164
  - `CreateChatResponseSchema` — Response for POST /api/chats
145
165
  - `DEFAULT_CHAT_RUNTIME_PROFILE` — Default profile when none is specified in the request.
166
+ - `FEEDBACK_COMMENT_MAX` — Longest free-text comment a down rating may carry.
167
+ - `FEEDBACK_RATINGS` — The two ratings a reader can give a turn.
168
+ - `FEEDBACK_REASONS` — Why a reader rated a turn down.
169
+ - `FeedbackRating` _(type)_
170
+ - `FeedbackRatingSchema`
171
+ - `FeedbackReason` _(type)_
172
+ - `FeedbackReasonSchema`
146
173
  - `GetChatResponse` _(type)_ — Response for GET /api/chats/:id
147
174
  - `GetChatResponseSchema` — Response for GET /api/chats/:id
148
175
  - `InvalidateChatEvent` _(type)_ — Single-entity staleness signal.
@@ -164,6 +191,8 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
164
191
  - `MessageActionIdSchema`
165
192
  - `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
166
193
  - `ProactiveChatResolutionSchema` — Resolving an occurrence id to THIS reader's chat (INV-PROACTIVE-LINK).
194
+ - `PutChatMessageFeedbackRequest` _(type)_
195
+ - `PutChatMessageFeedbackRequestSchema` — Body of PUT /api/chats/{id}/messages/{messageId}/feedback (operationId putChatMessageFeedback).
167
196
  - `RecognizedProactiveKind` _(type)_ — A wire `proactiveKind` after recognition: a kind this contracts version knows, or the raw string it did not —…
168
197
  - `ResolveLaunchResponse` _(type)_
169
198
  - `ResolveLaunchResponseSchema` — Response of the read-only launch resolution.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The sibling versions of one turn (response-versions-and-feedback-reasons,
3
+ * ADR-CONTRACTS-228).
4
+ *
5
+ * INVARIANTS TESTED:
6
+ * - `ChatMessage.alternatives` is null (no siblings) or a version position
7
+ * within the ordered sibling ids.
8
+ * - `ids` are uuids, `ids.length === total` and `1 <= position <= total`.
9
+ * - The branch-select request names one sibling by uuid `messageId`.
10
+ * - Both schemas and their types are reachable from the `./chat` barrel.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ ChatBranchSelectRequestSchema,
15
+ ChatMessageAlternativesSchema,
16
+ ChatMessageSchema,
17
+ } from "../schemas.js";
18
+ import type {
19
+ ChatBranchSelectRequest,
20
+ ChatMessageAlternatives,
21
+ } from "../index.js";
22
+ import * as barrel from "../index.js";
23
+
24
+ const A = "11111111-1111-4111-8111-111111111111";
25
+ const B = "22222222-2222-4222-8222-222222222222";
26
+
27
+ const makeMessage = (alternatives: unknown) => ({
28
+ id: "msg_1",
29
+ role: "assistant" as const,
30
+ content: "Done.",
31
+ sequenceNumber: 1,
32
+ createdAt: "2026-10-08T00:00:00.000Z",
33
+ mentions: [],
34
+ author: { kind: "agent" as const },
35
+ presentationGroupId: null,
36
+ turnKind: "conversation" as const,
37
+ alternatives,
38
+ myFeedback: null,
39
+ });
40
+
41
+ describe("ChatMessageAlternativesSchema", () => {
42
+ it("parses null alternatives on a message: the turn has no siblings", () => {
43
+ expect(ChatMessageSchema.parse(makeMessage(null)).alternatives).toBeNull();
44
+ });
45
+
46
+ it("accepts a version position within ordered sibling ids", () => {
47
+ const alternatives: ChatMessageAlternatives = {
48
+ position: 2,
49
+ total: 2,
50
+ ids: [A, B],
51
+ };
52
+ expect(
53
+ ChatMessageSchema.parse(makeMessage(alternatives)).alternatives,
54
+ ).toEqual(alternatives);
55
+ });
56
+
57
+ it("refuses ids whose length disagrees with total", () => {
58
+ expect(
59
+ ChatMessageAlternativesSchema.safeParse({
60
+ position: 1,
61
+ total: 3,
62
+ ids: [A, B],
63
+ }).success,
64
+ ).toBe(false);
65
+ });
66
+
67
+ it("refuses a position beyond total", () => {
68
+ expect(
69
+ ChatMessageAlternativesSchema.safeParse({
70
+ position: 3,
71
+ total: 2,
72
+ ids: [A, B],
73
+ }).success,
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("refuses a position below 1", () => {
78
+ expect(
79
+ ChatMessageAlternativesSchema.safeParse({
80
+ position: 0,
81
+ total: 2,
82
+ ids: [A, B],
83
+ }).success,
84
+ ).toBe(false);
85
+ });
86
+
87
+ it("refuses a non-uuid sibling id", () => {
88
+ expect(
89
+ ChatMessageAlternativesSchema.safeParse({
90
+ position: 1,
91
+ total: 1,
92
+ ids: ["msg_1"],
93
+ }).success,
94
+ ).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("ChatBranchSelectRequestSchema", () => {
99
+ it("accepts a uuid messageId", () => {
100
+ const body: ChatBranchSelectRequest = { messageId: A };
101
+ expect(ChatBranchSelectRequestSchema.safeParse(body).success).toBe(true);
102
+ });
103
+
104
+ it("refuses a missing or non-uuid messageId", () => {
105
+ expect(ChatBranchSelectRequestSchema.safeParse({}).success).toBe(false);
106
+ expect(
107
+ ChatBranchSelectRequestSchema.safeParse({ messageId: "msg_1" }).success,
108
+ ).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe("chat barrel exports the alternatives vocabulary", () => {
113
+ it("re-exports the same schemas the module defines", () => {
114
+ expect(barrel.ChatMessageAlternativesSchema).toBe(
115
+ ChatMessageAlternativesSchema,
116
+ );
117
+ expect(barrel.ChatBranchSelectRequestSchema).toBe(
118
+ ChatBranchSelectRequestSchema,
119
+ );
120
+ });
121
+ });
@@ -11,19 +11,37 @@
11
11
  * MessageActionContextSchema, messageActionsFor and isMessageActionAllowed
12
12
  * are exported from the chat barrel
13
13
  * - messageActionsFor called through the barrel is the same function the
14
- * backend enforces: a settled, latest conversation answer offers regenerate
14
+ * backend enforces: a settled conversation answer offers regenerate, latest
15
+ * or not
16
+ * - The versions and feedback vocabulary (response-versions-and-feedback-reasons)
17
+ * is exported from the chat barrel: ChatMessageAlternativesSchema,
18
+ * ChatBranchSelectRequestSchema, FEEDBACK_RATINGS, FeedbackRatingSchema,
19
+ * FEEDBACK_REASONS, FeedbackReasonSchema, FEEDBACK_COMMENT_MAX (2000),
20
+ * PutChatMessageFeedbackRequestSchema and ChatMessageMyFeedbackSchema
15
21
  */
16
22
  import { describe, expect, it } from "vitest";
17
23
  import {
24
+ ChatBranchSelectRequestSchema,
25
+ ChatMessageAlternativesSchema,
26
+ ChatMessageMyFeedbackSchema,
18
27
  ChatStreamMessageMetadataSchema,
28
+ FEEDBACK_COMMENT_MAX,
29
+ FEEDBACK_RATINGS,
30
+ FEEDBACK_REASONS,
31
+ FeedbackRatingSchema,
32
+ FeedbackReasonSchema,
19
33
  isMessageActionAllowed,
20
34
  MESSAGE_ACTION_IDS,
21
35
  MessageActionContextSchema,
22
36
  messageActionsFor,
37
+ PutChatMessageFeedbackRequestSchema,
23
38
  TURN_KINDS,
24
39
  TurnKindSchema,
25
40
  } from "../index.js";
26
41
 
42
+ const ID_A = "11111111-1111-4111-8111-111111111111";
43
+ const ID_B = "22222222-2222-4222-8222-222222222222";
44
+
27
45
  describe("chat barrel exports the turn on the wire", () => {
28
46
  it("exports the closed vocabularies", () => {
29
47
  expect(TURN_KINDS).toContain("conversation");
@@ -58,4 +76,58 @@ describe("chat barrel exports the turn on the wire", () => {
58
76
  expect(isMessageActionAllowed(ctx, "regenerate")).toBe(true);
59
77
  expect(isMessageActionAllowed(ctx, "edit")).toBe(false);
60
78
  });
79
+
80
+ it("offers regenerate on an older answer through the barrel", () => {
81
+ const ctx = MessageActionContextSchema.parse({
82
+ role: "assistant",
83
+ turnKind: "conversation",
84
+ settled: true,
85
+ isLatestOnActiveBranch: false,
86
+ authoredByReader: false,
87
+ });
88
+ expect(isMessageActionAllowed(ctx, "regenerate")).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("chat barrel exports the versions and feedback vocabulary", () => {
93
+ it("exports the alternatives and branch-select schemas", () => {
94
+ expect(
95
+ ChatMessageAlternativesSchema.safeParse({
96
+ position: 2,
97
+ total: 2,
98
+ ids: [ID_A, ID_B],
99
+ }).success,
100
+ ).toBe(true);
101
+ expect(
102
+ ChatBranchSelectRequestSchema.safeParse({ messageId: ID_B }).success,
103
+ ).toBe(true);
104
+ });
105
+
106
+ it("exports the closed feedback vocabularies", () => {
107
+ expect(FEEDBACK_RATINGS).toEqual(["up", "down"]);
108
+ expect(FeedbackRatingSchema.options).toEqual([...FEEDBACK_RATINGS]);
109
+ expect(FEEDBACK_REASONS).toContain("other");
110
+ expect(FeedbackReasonSchema.options).toEqual([...FEEDBACK_REASONS]);
111
+ expect(FEEDBACK_COMMENT_MAX).toBe(2000);
112
+ });
113
+
114
+ it("exports the feedback request and myFeedback schemas", () => {
115
+ expect(
116
+ PutChatMessageFeedbackRequestSchema.safeParse({
117
+ rating: "down",
118
+ reason: "incorrect",
119
+ comment: "wrong date",
120
+ }).success,
121
+ ).toBe(true);
122
+ expect(
123
+ PutChatMessageFeedbackRequestSchema.safeParse({
124
+ rating: "up",
125
+ reason: "incorrect",
126
+ }).success,
127
+ ).toBe(false);
128
+ expect(
129
+ ChatMessageMyFeedbackSchema.safeParse({ rating: "up", reason: null })
130
+ .success,
131
+ ).toBe(true);
132
+ });
61
133
  });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Chat message feedback (response-versions-and-feedback-reasons).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - A reason or a comment rides only a "down" rating; an up rating carrying
6
+ * either is refused.
7
+ * - A comment is at most FEEDBACK_COMMENT_MAX (2000) characters.
8
+ * - FEEDBACK_REASONS is CLOSED: a reason outside it is refused.
9
+ * - ChatMessageMyFeedback is `{ rating, reason }` with a nullable reason.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import {
13
+ ChatMessageMyFeedbackSchema,
14
+ FEEDBACK_COMMENT_MAX,
15
+ FEEDBACK_RATINGS,
16
+ FEEDBACK_REASONS,
17
+ PutChatMessageFeedbackRequestSchema,
18
+ } from "../feedback.js";
19
+
20
+ describe("PutChatMessageFeedbackRequestSchema", () => {
21
+ it("accepts a bare up or down rating", () => {
22
+ for (const rating of FEEDBACK_RATINGS) {
23
+ expect(
24
+ PutChatMessageFeedbackRequestSchema.safeParse({ rating }).success,
25
+ ).toBe(true);
26
+ }
27
+ });
28
+
29
+ it("accepts a down rating with a reason and a comment", () => {
30
+ expect(
31
+ PutChatMessageFeedbackRequestSchema.safeParse({
32
+ rating: "down",
33
+ reason: "incorrect",
34
+ comment: "wrong date",
35
+ }).success,
36
+ ).toBe(true);
37
+ });
38
+
39
+ it("refuses a reason with rating up", () => {
40
+ expect(
41
+ PutChatMessageFeedbackRequestSchema.safeParse({
42
+ rating: "up",
43
+ reason: "other",
44
+ }).success,
45
+ ).toBe(false);
46
+ });
47
+
48
+ it("refuses a comment with rating up", () => {
49
+ expect(
50
+ PutChatMessageFeedbackRequestSchema.safeParse({
51
+ rating: "up",
52
+ comment: "nice",
53
+ }).success,
54
+ ).toBe(false);
55
+ });
56
+
57
+ it("refuses a comment over 2000 characters", () => {
58
+ expect(FEEDBACK_COMMENT_MAX).toBe(2000);
59
+ expect(
60
+ PutChatMessageFeedbackRequestSchema.safeParse({
61
+ rating: "down",
62
+ comment: "x".repeat(FEEDBACK_COMMENT_MAX + 1),
63
+ }).success,
64
+ ).toBe(false);
65
+ expect(
66
+ PutChatMessageFeedbackRequestSchema.safeParse({
67
+ rating: "down",
68
+ comment: "x".repeat(FEEDBACK_COMMENT_MAX),
69
+ }).success,
70
+ ).toBe(true);
71
+ });
72
+
73
+ it("refuses a reason outside FEEDBACK_REASONS", () => {
74
+ expect(
75
+ PutChatMessageFeedbackRequestSchema.safeParse({
76
+ rating: "down",
77
+ reason: "rude",
78
+ }).success,
79
+ ).toBe(false);
80
+ expect(FEEDBACK_REASONS).toContain("other");
81
+ });
82
+ });
83
+
84
+ describe("ChatMessageMyFeedbackSchema", () => {
85
+ it("carries a rating and a nullable reason", () => {
86
+ expect(
87
+ ChatMessageMyFeedbackSchema.parse({ rating: "up", reason: null }),
88
+ ).toEqual({ rating: "up", reason: null });
89
+ expect(
90
+ ChatMessageMyFeedbackSchema.parse({ rating: "down", reason: "other" }),
91
+ ).toEqual({ rating: "down", reason: "other" });
92
+ });
93
+
94
+ it("requires the reason key", () => {
95
+ expect(
96
+ ChatMessageMyFeedbackSchema.safeParse({ rating: "up" }).success,
97
+ ).toBe(false);
98
+ });
99
+ });
@@ -6,6 +6,8 @@
6
6
  * - One test per row of the doctrine matrix.
7
7
  * - An unsettled turn offers nothing.
8
8
  * - Regenerate is never offered on a launch, settlement or input-request turn.
9
+ * - Regenerate and edit are offered on older turns too — position gates
10
+ * nothing, because both branch rather than delete.
9
11
  * - Total over TURN_KINDS; an unknown kind or role is never permissive.
10
12
  * - isMessageActionAllowed agrees with messageActionsFor.
11
13
  */
@@ -74,7 +76,7 @@ describe("messageActionsFor", () => {
74
76
  ).toEqual(["copy", "edit"]);
75
77
  });
76
78
 
77
- it("a user turn that is not latest, or not the reader's own, offers copy only", () => {
79
+ it("an older user turn that is the reader's own still offers copy and edit (edit branches)", () => {
78
80
  expect(
79
81
  messageActionsFor(
80
82
  makeCtx({
@@ -83,10 +85,21 @@ describe("messageActionsFor", () => {
83
85
  isLatestOnActiveBranch: false,
84
86
  }),
85
87
  ),
86
- ).toEqual(["copy"]);
87
- expect(
88
- messageActionsFor(makeCtx({ role: "user", authoredByReader: false })),
89
- ).toEqual(["copy"]);
88
+ ).toEqual(["copy", "edit"]);
89
+ });
90
+
91
+ it("a user turn that is not the reader's own offers copy only, latest or not", () => {
92
+ for (const isLatestOnActiveBranch of [true, false]) {
93
+ expect(
94
+ messageActionsFor(
95
+ makeCtx({
96
+ role: "user",
97
+ authoredByReader: false,
98
+ isLatestOnActiveBranch,
99
+ }),
100
+ ),
101
+ ).toEqual(["copy"]);
102
+ }
90
103
  });
91
104
 
92
105
  it("the latest assistant conversation turn offers copy, feedback and regenerate", () => {
@@ -97,10 +110,25 @@ describe("messageActionsFor", () => {
97
110
  ]);
98
111
  });
99
112
 
100
- it("an older assistant conversation turn offers copy and feedback", () => {
113
+ it("offers regenerate on a historical conversation answer", () => {
101
114
  expect(
102
115
  messageActionsFor(makeCtx({ isLatestOnActiveBranch: false })),
103
- ).toEqual(["copy", "feedback"]);
116
+ ).toEqual(["copy", "feedback", "regenerate"]);
117
+ });
118
+
119
+ it("an older unsettled conversation turn still offers nothing", () => {
120
+ for (const role of ["user", "assistant"] as const) {
121
+ expect(
122
+ messageActionsFor(
123
+ makeCtx({
124
+ role,
125
+ settled: false,
126
+ isLatestOnActiveBranch: false,
127
+ authoredByReader: true,
128
+ }),
129
+ ),
130
+ ).toEqual([]);
131
+ }
104
132
  });
105
133
 
106
134
  it("never offers regenerate on a workflow_launch, workflow_settlement or input_request_launch turn", () => {
@@ -117,9 +145,13 @@ describe("messageActionsFor", () => {
117
145
  }
118
146
  });
119
147
 
120
- it("a proactive or refusal turn offers copy only", () => {
148
+ it("a proactive or refusal turn offers copy only, latest or not", () => {
121
149
  for (const turnKind of ["proactive", "refusal"] as const) {
122
- expect(messageActionsFor(makeCtx({ turnKind }))).toEqual(["copy"]);
150
+ for (const isLatestOnActiveBranch of [true, false]) {
151
+ expect(
152
+ messageActionsFor(makeCtx({ turnKind, isLatestOnActiveBranch })),
153
+ ).toEqual(["copy"]);
154
+ }
123
155
  }
124
156
  });
125
157
 
@@ -57,6 +57,7 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
57
57
  // REQUIRED turn identity — pinned in ./turn-wire.test.ts.
58
58
  presentationGroupId: null,
59
59
  turnKind: "conversation" as const,
60
+ alternatives: null,
60
61
  myFeedback: null,
61
62
  ...over,
62
63
  });
@@ -55,6 +55,7 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
55
55
  author: userPrincipal("user_1"),
56
56
  presentationGroupId: null,
57
57
  turnKind: "conversation" as const,
58
+ alternatives: null,
58
59
  myFeedback: null,
59
60
  ...over,
60
61
  });
@@ -9,7 +9,11 @@
9
9
  * - A null group and a null rating parse: the turn stands alone / is unrated.
10
10
  * - The group is a uuid; anything else is refused.
11
11
  * - `turnKind` is CLOSED: an unknown kind is refused, never passed through.
12
- * - `myFeedback` is exactly `up`, `down` or null.
12
+ * - `myFeedback` is `{ rating, reason }` or null: the rating is exactly `up`
13
+ * or `down`, the reason one of the CLOSED FEEDBACK_REASONS or null, and the
14
+ * old bare-string shape is refused.
15
+ * - `alternatives` is REQUIRED; null means the turn has no sibling versions
16
+ * (the shape of a present value is pinned in alternatives.test.ts).
13
17
  */
14
18
  import { describe, expect, it } from "vitest";
15
19
  import { userPrincipal } from "../../identity/agent";
@@ -27,7 +31,8 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
27
31
  author: { kind: "agent" as const },
28
32
  presentationGroupId: "3f2b8c1e-6d4a-4f7b-9a2e-1c5d8e9f0a1b",
29
33
  turnKind: "workflow_settlement" as const,
30
- myFeedback: "up" as const,
34
+ alternatives: null,
35
+ myFeedback: { rating: "up" as const, reason: null },
31
36
  ...over,
32
37
  });
33
38
 
@@ -86,8 +91,42 @@ describe("ChatMessageSchema turn identity", () => {
86
91
 
87
92
  it("refuses a rating other than up, down or null", () => {
88
93
  expect(
89
- ChatMessageSchema.safeParse(makeMessage({ myFeedback: "neutral" }))
90
- .success,
94
+ ChatMessageSchema.safeParse(
95
+ makeMessage({ myFeedback: { rating: "neutral", reason: null } }),
96
+ ).success,
97
+ ).toBe(false);
98
+ });
99
+
100
+ it("refuses the bare-string rating shape", () => {
101
+ expect(
102
+ ChatMessageSchema.safeParse(makeMessage({ myFeedback: "up" })).success,
91
103
  ).toBe(false);
92
104
  });
105
+
106
+ it("parses a down rating with a closed reason", () => {
107
+ const parsed = ChatMessageSchema.parse(
108
+ makeMessage({ myFeedback: { rating: "down", reason: "incorrect" } }),
109
+ );
110
+ expect(parsed.myFeedback).toEqual({ rating: "down", reason: "incorrect" });
111
+ });
112
+
113
+ it("refuses a reason outside FEEDBACK_REASONS", () => {
114
+ expect(
115
+ ChatMessageSchema.safeParse(
116
+ makeMessage({ myFeedback: { rating: "down", reason: "rude" } }),
117
+ ).success,
118
+ ).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("ChatMessageSchema alternatives", () => {
123
+ it("refuses a message without alternatives", () => {
124
+ expect(ChatMessageSchema.safeParse(without("alternatives")).success).toBe(
125
+ false,
126
+ );
127
+ });
128
+
129
+ it("parses null alternatives: the turn has no siblings", () => {
130
+ expect(ChatMessageSchema.parse(makeMessage()).alternatives).toBeNull();
131
+ });
93
132
  });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Chat message feedback (response-versions-and-feedback-reasons,
3
+ * ADR-CONTRACTS-228).
4
+ *
5
+ * Feedback MEASURES a message. It is never approval of anything the message
6
+ * proposed (chat-action-system rule 8): a thumbs-up does not authorize,
7
+ * confirm or execute.
8
+ *
9
+ * INVARIANTS:
10
+ * - FEEDBACK_REASONS is CLOSED and append-only.
11
+ * - A reason or a comment rides only a "down" rating.
12
+ * - LOAD ORDER: this module is a LEAF. It imports only zod and must NEVER
13
+ * import `./schemas` (or any module that does) — `./schemas` imports this
14
+ * file, and the reverse edge would reach its declarations in the temporal
15
+ * dead zone.
16
+ */
17
+
18
+ import { z } from "zod";
19
+
20
+ /** The two ratings a reader can give a turn. */
21
+ export const FEEDBACK_RATINGS = ["up", "down"] as const;
22
+ export const FeedbackRatingSchema = z.enum(FEEDBACK_RATINGS);
23
+ export type FeedbackRating = z.infer<typeof FeedbackRatingSchema>;
24
+
25
+ /**
26
+ * Why a reader rated a turn down. CLOSED and append-only: a reason is a
27
+ * category the product can count, so free text belongs in `comment`, never
28
+ * here.
29
+ */
30
+ export const FEEDBACK_REASONS = [
31
+ /** The answer states something false. */
32
+ "incorrect",
33
+ /** The answer ignored what the prompt asked for. */
34
+ "didnt_follow_instructions",
35
+ /** Accurate, but it did not help. */
36
+ "not_helpful",
37
+ /** The answer lacked context it should have had. */
38
+ "missing_context",
39
+ /** A tool call or launched workflow misbehaved. */
40
+ "tool_or_workflow_issue",
41
+ /** None of the above; the comment says why. */
42
+ "other",
43
+ ] as const;
44
+ export const FeedbackReasonSchema = z.enum(FEEDBACK_REASONS);
45
+ export type FeedbackReason = z.infer<typeof FeedbackReasonSchema>;
46
+
47
+ /** Longest free-text comment a down rating may carry. */
48
+ export const FEEDBACK_COMMENT_MAX = 2000;
49
+
50
+ /**
51
+ * Body of PUT /api/chats/{id}/messages/{messageId}/feedback (operationId
52
+ * putChatMessageFeedback). Named after the generated operation's component so
53
+ * the backend can replace its route-local schema of the same name.
54
+ */
55
+ export const PutChatMessageFeedbackRequestSchema = z
56
+ .object({
57
+ rating: FeedbackRatingSchema,
58
+ reason: FeedbackReasonSchema.optional(),
59
+ comment: z.string().max(FEEDBACK_COMMENT_MAX).optional(),
60
+ })
61
+ .refine(
62
+ (b) =>
63
+ b.rating === "down" ||
64
+ (b.reason === undefined && b.comment === undefined),
65
+ { message: "reason and comment ride only a down rating" },
66
+ );
67
+ export type PutChatMessageFeedbackRequest = z.infer<
68
+ typeof PutChatMessageFeedbackRequestSchema
69
+ >;
70
+
71
+ /**
72
+ * The reader's own rating as `ChatMessage.myFeedback` carries it. `reason` is
73
+ * null on an up rating and on a down rating given without one. The comment is
74
+ * write-only — it is never projected back onto the transcript.
75
+ */
76
+ export const ChatMessageMyFeedbackSchema = z.object({
77
+ rating: FeedbackRatingSchema,
78
+ reason: FeedbackReasonSchema.nullable(),
79
+ });
80
+ export type ChatMessageMyFeedback = z.infer<typeof ChatMessageMyFeedbackSchema>;
package/src/chat/index.ts CHANGED
@@ -41,6 +41,10 @@ export type {
41
41
  ChatListResponse,
42
42
  CreateChatResponse,
43
43
  ChatMessage,
44
+ // Sibling versions of a turn and the branch-select request body
45
+ // (response-versions-and-feedback-reasons, ADR-CONTRACTS-228)
46
+ ChatMessageAlternatives,
47
+ ChatBranchSelectRequest,
44
48
  // Mentions on a chat turn (ADR-CONTRACTS-160): a PrincipalRef target, so the
45
49
  // agent is a legal mention target on the chat surface too.
46
50
  ChatMessageMention,
@@ -88,6 +92,8 @@ export {
88
92
  ChatListResponseSchema,
89
93
  CreateChatResponseSchema,
90
94
  ChatMessageSchema,
95
+ ChatMessageAlternativesSchema,
96
+ ChatBranchSelectRequestSchema,
91
97
  ChatMessageMentionSchema,
92
98
  ChatMentionableResponseSchema,
93
99
  // Participants: the role vocabulary, one participant row, and the list
@@ -155,6 +161,26 @@ export {
155
161
  isMessageActionAllowed,
156
162
  } from "./message-actions";
157
163
 
164
+ // Feedback (response-versions-and-feedback-reasons): the rating vocabulary,
165
+ // the CLOSED reasons a down rating may carry, the PUT feedback request body and
166
+ // the reader's own rating as ChatMessage.myFeedback carries it. Feedback
167
+ // measures a turn; it is never approval.
168
+ export type {
169
+ FeedbackRating,
170
+ FeedbackReason,
171
+ PutChatMessageFeedbackRequest,
172
+ ChatMessageMyFeedback,
173
+ } from "./feedback";
174
+ export {
175
+ FEEDBACK_RATINGS,
176
+ FeedbackRatingSchema,
177
+ FEEDBACK_REASONS,
178
+ FeedbackReasonSchema,
179
+ FEEDBACK_COMMENT_MAX,
180
+ PutChatMessageFeedbackRequestSchema,
181
+ ChatMessageMyFeedbackSchema,
182
+ } from "./feedback";
183
+
158
184
  // Runtime profile types and constants
159
185
  export type {
160
186
  ChatRuntimeProfile,
@@ -18,8 +18,11 @@
18
18
  * - Regenerate is never offered on a workflow_launch, workflow_settlement,
19
19
  * input_request_launch, proactive or refusal turn: it would ask the model for
20
20
  * new prose without re-running the work.
21
- * - Regenerate and edit are offered only on the latest turn of the active
22
- * branch; edit only to the reader who authored it.
21
+ * - Regenerate and edit are offered on ANY settled conversation turn, not only
22
+ * the latest: both BRANCH (add a sibling version) and never delete, so an
23
+ * older turn is as safe to act on as the latest. Edit only to the reader who
24
+ * authored the prompt (response-versions-and-feedback-reasons).
25
+ * - Position (`isLatestOnActiveBranch`) is on the context but gates NOTHING.
23
26
  * - An input the schema does not recognize (an unknown kind or role) offers
24
27
  * NOTHING. Unknown is never permissive.
25
28
  *
@@ -39,9 +42,9 @@ export const MESSAGE_ACTION_IDS = [
39
42
  "copy",
40
43
  /** Rate the turn up or down. Measures a message; never approves anything. */
41
44
  "feedback",
42
- /** A new answer to the same request; supersedes the old one. Never re-runs work. */
45
+ /** A new answer to the same request; adds a sibling version. Never re-runs work. */
43
46
  "regenerate",
44
- /** Rewrite the reader's own prompt; supersedes the branch after it. */
47
+ /** Rewrite the reader's own prompt; adds a sibling version of it. */
45
48
  "edit",
46
49
  ] as const;
47
50
 
@@ -60,17 +63,24 @@ export const MessageActionContextSchema = z.object({
60
63
  * flight or awaiting settlement delivery.
61
64
  */
62
65
  settled: z.boolean(),
63
- /** The turn is the latest of its role on the active branch. */
66
+ /**
67
+ * The turn is the latest of its role on the active branch. Carried for the
68
+ * reader's orientation; it no longer gates any action — regenerate and edit
69
+ * branch, so they are offered on older turns too.
70
+ */
64
71
  isLatestOnActiveBranch: z.boolean(),
65
72
  /** The reader is the turn's author (a user turn they wrote). */
66
73
  authoredByReader: z.boolean(),
67
74
  });
68
75
  export type MessageActionContext = z.infer<typeof MessageActionContextSchema>;
69
76
 
70
- /** `always` is offered on every settled turn; `latest` only on the latest. */
77
+ /**
78
+ * `always` is offered on every settled turn; `authored` only on a settled user
79
+ * turn the reader wrote.
80
+ */
71
81
  type ActionRule = {
72
82
  always: readonly MessageActionId[];
73
- latest: readonly MessageActionId[];
83
+ authored: readonly MessageActionId[];
74
84
  };
75
85
 
76
86
  /**
@@ -81,28 +91,28 @@ type ActionRule = {
81
91
  */
82
92
  const TURN_ACTIONS = {
83
93
  conversation: {
84
- user: { always: ["copy"], latest: ["edit"] },
85
- assistant: { always: ["copy", "feedback"], latest: ["regenerate"] },
94
+ user: { always: ["copy"], authored: ["edit"] },
95
+ assistant: { always: ["copy", "feedback", "regenerate"], authored: [] },
86
96
  },
87
97
  workflow_launch: {
88
- user: { always: ["copy"], latest: [] },
89
- assistant: { always: ["copy", "feedback"], latest: [] },
98
+ user: { always: ["copy"], authored: [] },
99
+ assistant: { always: ["copy", "feedback"], authored: [] },
90
100
  },
91
101
  workflow_settlement: {
92
- user: { always: ["copy"], latest: [] },
93
- assistant: { always: ["copy", "feedback"], latest: [] },
102
+ user: { always: ["copy"], authored: [] },
103
+ assistant: { always: ["copy", "feedback"], authored: [] },
94
104
  },
95
105
  input_request_launch: {
96
- user: { always: ["copy"], latest: [] },
97
- assistant: { always: ["copy", "feedback"], latest: [] },
106
+ user: { always: ["copy"], authored: [] },
107
+ assistant: { always: ["copy", "feedback"], authored: [] },
98
108
  },
99
109
  proactive: {
100
- user: { always: ["copy"], latest: [] },
101
- assistant: { always: ["copy"], latest: [] },
110
+ user: { always: ["copy"], authored: [] },
111
+ assistant: { always: ["copy"], authored: [] },
102
112
  },
103
113
  refusal: {
104
- user: { always: ["copy"], latest: [] },
105
- assistant: { always: ["copy"], latest: [] },
114
+ user: { always: ["copy"], authored: [] },
115
+ assistant: { always: ["copy"], authored: [] },
106
116
  },
107
117
  } as const satisfies Record<
108
118
  TurnKind,
@@ -114,24 +124,20 @@ const TURN_ACTIONS = {
114
124
  *
115
125
  * - Input that does not parse (unknown kind, unknown role) → [].
116
126
  * - Not settled → [].
117
- * - User turn → its `always`, plus `latest` iff latest on the active branch
118
- * AND authored by the reader.
119
- * - Assistant turn → its `always`, plus `latest` iff latest on the active
120
- * branch.
127
+ * - User turn → its `always`, plus `authored` iff authored by the reader.
128
+ * - Assistant turn → its `always`.
129
+ * - Position on the branch gates nothing.
121
130
  */
122
131
  export function messageActionsFor(
123
132
  ctx: MessageActionContext,
124
133
  ): readonly MessageActionId[] {
125
134
  const parsed = MessageActionContextSchema.safeParse(ctx);
126
135
  if (!parsed.success || !parsed.data.settled) return [];
127
- const { role, turnKind, isLatestOnActiveBranch, authoredByReader } =
128
- parsed.data;
136
+ const { role, turnKind, authoredByReader } = parsed.data;
129
137
  const rule: ActionRule = TURN_ACTIONS[turnKind][role];
130
- const latest =
131
- role === "user"
132
- ? isLatestOnActiveBranch && authoredByReader
133
- : isLatestOnActiveBranch;
134
- return latest ? [...rule.always, ...rule.latest] : [...rule.always];
138
+ return role === "user" && authoredByReader
139
+ ? [...rule.always, ...rule.authored]
140
+ : [...rule.always];
135
141
  }
136
142
 
137
143
  /** Whether `action` is among `messageActionsFor(ctx)` — the backend's check. */
@@ -25,6 +25,7 @@ import {
25
25
  proactiveChatFields,
26
26
  TitleSourceSchema,
27
27
  } from "./enums";
28
+ import { ChatMessageMyFeedbackSchema } from "./feedback";
28
29
  import { TurnKindSchema } from "./turn-kind";
29
30
 
30
31
  // The chat vocabulary and the SSE event contract, re-exported so this module
@@ -238,6 +239,41 @@ export const ChatDiscussionScopeSchema = z
238
239
  })
239
240
  .strict(); // an unknown key is an ERROR, not surplus
240
241
 
242
+ /**
243
+ * The VERSIONS of one turn (response-versions-and-feedback-reasons,
244
+ * ADR-CONTRACTS-228). Regenerating an answer or editing a prompt never deletes
245
+ * the turn it replaces — it ADDS A SIBLING VERSION, and the reader picks which
246
+ * one the active branch runs through.
247
+ *
248
+ * `ids` lists every sibling, this one included, ORDERED BY CREATION — so the
249
+ * selector reads "2 / 3" from `position` / `total` and steps through `ids`
250
+ * without a second fetch. `position` is 1-based. The refinements make the three
251
+ * fields agree by construction: a client never has to reconcile a `total` that
252
+ * disagrees with the list it was sent.
253
+ */
254
+ export const ChatMessageAlternativesSchema = z
255
+ .object({
256
+ position: z.number().int().min(1),
257
+ total: z.number().int().min(1),
258
+ ids: z.array(z.string().uuid()),
259
+ })
260
+ .refine((a) => a.ids.length === a.total, {
261
+ message: "ids.length must equal total",
262
+ })
263
+ .refine((a) => a.position <= a.total, {
264
+ message: "position must not exceed total",
265
+ });
266
+
267
+ /**
268
+ * Request body that makes one version the active branch
269
+ * (response-versions-and-feedback-reasons). `messageId` names the SIBLING to
270
+ * select; the server re-checks it is a version of a turn in this chat — naming
271
+ * an id is a lookup, not authority.
272
+ */
273
+ export const ChatBranchSelectRequestSchema = z.object({
274
+ messageId: z.string().uuid(),
275
+ });
276
+
241
277
  export const ChatMessageSchema = z.object({
242
278
  id: z.string(),
243
279
  role: z.enum(["user", "assistant"]),
@@ -309,12 +345,26 @@ export const ChatMessageSchema = z.object({
309
345
  */
310
346
  turnKind: TurnKindSchema,
311
347
 
348
+ /**
349
+ * The sibling VERSIONS of this turn, when it has been regenerated (answer)
350
+ * or edited (prompt) — see {@link ChatMessageAlternativesSchema}. REQUIRED,
351
+ * and null means this turn has no siblings: every row has an answer, so there
352
+ * is no absence to make optional. The transcript carries the ACTIVE branch
353
+ * only; the other versions are reachable by id, never inlined.
354
+ */
355
+ alternatives: ChatMessageAlternativesSchema.nullable(),
356
+
312
357
  /**
313
358
  * The READER's current rating of this turn — the person this response was
314
359
  * projected for, never an aggregate of other participants. REQUIRED so a
315
360
  * reload restores the rating; null on user turns and on unrated turns.
361
+ *
362
+ * `{ rating, reason }` (response-versions-and-feedback-reasons): `reason` is
363
+ * one of the CLOSED FEEDBACK_REASONS, and only ever set on a down rating.
364
+ * Feedback MEASURES the turn — it is NOT APPROVAL of anything the turn
365
+ * proposed, and never authorizes, confirms or executes.
316
366
  */
317
- myFeedback: z.enum(["up", "down"]).nullable(),
367
+ myFeedback: ChatMessageMyFeedbackSchema.nullable(),
318
368
  });
319
369
 
320
370
  // =============================================================================
package/src/chat/types.ts CHANGED
@@ -30,6 +30,8 @@ import {
30
30
  ChatListResponseSchema,
31
31
  CreateChatResponseSchema,
32
32
  ChatMessageSchema,
33
+ ChatMessageAlternativesSchema,
34
+ ChatBranchSelectRequestSchema,
33
35
  ChatMessageMentionSchema,
34
36
  ChatMentionableResponseSchema,
35
37
  ChatParticipantRoleSchema,
@@ -227,6 +229,19 @@ export type CreateChatResponse = z.infer<typeof CreateChatResponseSchema>;
227
229
  /** Individual message in a chat response (GET /api/chats/:id). */
228
230
  export type ChatMessage = z.infer<typeof ChatMessageSchema>;
229
231
 
232
+ /**
233
+ * The sibling versions of one turn — 1-based `position`, `total`, and every
234
+ * sibling id ordered by creation (response-versions-and-feedback-reasons).
235
+ */
236
+ export type ChatMessageAlternatives = z.infer<
237
+ typeof ChatMessageAlternativesSchema
238
+ >;
239
+
240
+ /** Request body that makes one sibling version the active branch. */
241
+ export type ChatBranchSelectRequest = z.infer<
242
+ typeof ChatBranchSelectRequestSchema
243
+ >;
244
+
230
245
  /**
231
246
  * One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16
232
247
  * code-unit range into `content`, and a read-time display name.
@@ -17,6 +17,7 @@ export const openApiRoutes = {
17
17
  '/api/analysis-workflows/{id}/settlement': ['POST'],
18
18
  '/api/analysis-workflows/{id}/stop': ['POST'],
19
19
  '/api/auth/consent/grant': ['POST'],
20
+ '/api/billing/price-book-versions': ['GET'],
20
21
  '/api/billing/price-list': ['GET'],
21
22
  '/api/capabilities': ['GET'],
22
23
  '/api/chat/interactive-tasks/submit': ['POST'],