macp-sdk-typescript 0.14.0 → 0.14.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,75 @@ All notable changes to `macp-sdk-typescript` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project uses [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.14.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.14.0...v0.14.1) (2026-10-03)
8
+
9
+
10
+ ### Bug Fixes
11
+
12
+ * **parity:** re-vendor contract.json for contract_version 1.3.0 ([#156](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/156)) ([#157](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/157)) ([497949d](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/497949d2b29031d340f5d8cc2ef17ae34c946c42))
13
+
14
+ ## [Unreleased]
15
+
16
+ ### Added
17
+
18
+ - **`TaskProjection.rejections: TaskRejectRecord[]`** (issue #151; a
19
+ cross-SDK parity audit against `macp-sdk-python`'s `TaskRejectRecord`,
20
+ `src/macp_sdk/task.py:53-58`). `TaskReject` now appends an unconditional
21
+ audit record — `taskId`, `assignee`, `reason?`, `sender` (from the
22
+ envelope, not the payload) — matching the convention already established
23
+ for `updates`/`completions`/`failures`. A `TaskReject` for an unknown
24
+ `taskId` still records a `rejections` entry (no crash, no silent drop) but
25
+ creates no `tasks` entry, unchanged from today. Deliberately does not fall
26
+ back `assignee` to the envelope sender the way Python's `p.assignee or
27
+ envelope.sender` does — this SDK's own sibling `TaskComplete`/`TaskFail`
28
+ handlers spread the payload's `assignee` as-is with no such fallback, and
29
+ intra-SDK consistency with that established pattern takes precedence here.
30
+
31
+ ### Fixed
32
+
33
+ - **`BaseProjection` phase no longer regresses once `Committed`, and
34
+ `DecisionProjection` no longer lets a replayed `Proposal` rewind phase
35
+ from `Voting` back to `Evaluation` after voting has begun** (issue #150,
36
+ issue #153; found by a cross-SDK parity audit against `macp-sdk-python`'s
37
+ `_set_phase()`, `src/macp_sdk/base_projection.py:289-313`). All five
38
+ built-in mode projections now route every phase write through a new
39
+ shared, protected `setPhase()` choke point on `BaseProjection` that
40
+ no-ops once `phase === 'Committed'`, instead of assigning `this.phase`
41
+ directly at each of the (previously) 12 call sites across
42
+ `decision.ts`/`proposal.ts`/`task.ts`/`quorum.ts`/`handoff.ts` — only
43
+ `decision.ts`'s own Vote-branch guard protected this before. RFC-MACP-0007
44
+ §5 rule 6 separately requires a runtime to reject any
45
+ `Proposal`/`Evaluation`/`Objection` after the first accepted `Vote`;
46
+ `DecisionProjection`'s `Proposal` handler now guards on the pre-
47
+ `Evaluation` phase value (`if (this.phase === 'Proposal')
48
+ this.setPhase('Evaluation')`) so a replayed/retried `Proposal` (same
49
+ `proposalId`, a distinct `messageId` that still passes
50
+ `applyEnvelope`'s `message_id` dedup gate) can no longer rewind `phase`
51
+ back to `'Evaluation'` once voting has begun. `BaseProjection.
52
+ applyEnvelope`'s own direct `this.phase = 'Committed'` assignment for a
53
+ `Commitment` envelope is deliberately left untouched — exact parity with
54
+ Python's own `_set_phase()` docstring, which documents the same bypass.
55
+ - **`buildDecisionPolicy` now rejects a `+Infinity` voting weight** (issue
56
+ #152; found by a cross-SDK parity audit against `macp-sdk-python`'s
57
+ `math.isfinite` guard, `src/macp_sdk/policy.py:186`). The per-weight
58
+ validation guard changed from `weight <= 0 || Number.isNaN(weight)` to
59
+ `weight <= 0 || !Number.isFinite(weight)` — `!Number.isFinite` is `true`
60
+ for `NaN`, `+Infinity`, and `-Infinity` alike, closing the `+Infinity` gap
61
+ the old `Number.isNaN`-only check missed (a `+Infinity` weight serializes
62
+ to JSON `null`, which is schema-invalid against
63
+ `decision-rules.schema.json`). `-Infinity`/`NaN` continue to throw,
64
+ unchanged.
65
+ - **`watchers.ts`'s 5 "stream ended before receiving a ..." sites now throw
66
+ `MacpTransportError` instead of a bare `Error`** (issue #154; parity with
67
+ `macp-sdk-python` PR #138). `ModeRegistryWatcher.nextChange()`,
68
+ `RootsWatcher.nextChange()`, `SignalWatcher.nextSignal()`,
69
+ `PolicyWatcher.nextChange()`, and `SessionLifecycleWatcher.nextChange()`
70
+ each threw a bare `Error` when their stream ended before yielding a value;
71
+ a caller's single `catch (e) { if (e instanceof MacpSdkError) ... }` now
72
+ covers a cleanly-ended watch stream alongside every other SDK failure.
73
+ Message strings are unchanged byte-for-byte; `error.code` remains
74
+ `undefined` on all 5 (locally raised, no gRPC status to attach).
75
+
7
76
  ## [0.14.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.13.0...v0.14.0) (2026-10-01)
8
77
 
9
78
 
package/dist/policy.js CHANGED
@@ -105,9 +105,13 @@ function buildDecisionPolicy(policyId, description, rules, options) {
105
105
  }
106
106
  for (const [participant, weight] of Object.entries(weights)) {
107
107
  // weight <= 0 alone would let a NaN weight slip through (NaN <= 0 is
108
- // false); the runtime guards this explicitly (registry.rs:596), mirrored
109
- // here even though the Python reference does not check it separately.
110
- if (weight <= 0 || Number.isNaN(weight)) {
108
+ // false); the runtime guards this explicitly (registry.rs:596).
109
+ // !Number.isFinite(weight) is true for NaN, +Infinity, and -Infinity
110
+ // alike -- the exact mirror of Python's own guard, policy.py:186:
111
+ // `if not math.isfinite(weight) or weight <= 0:` (issue #152; a
112
+ // +Infinity weight previously slipped past the old Number.isNaN-only
113
+ // check and serialized to JSON `null`, which is schema-invalid).
114
+ if (weight <= 0 || !Number.isFinite(weight)) {
111
115
  throw new errors_1.MacpSessionError(`weights['${participant}'] must be > 0, got ${weight} -- a weight-0 observer is expressed by ` +
112
116
  'omission from the map, not by an explicit 0');
113
117
  }
@@ -226,4 +226,18 @@ export declare abstract class BaseProjection {
226
226
  * `MACP_LOG_LEVEL` environment variable.
227
227
  */
228
228
  protected recordAnomaly(anomaly: ProjectionAnomaly): void;
229
+ /**
230
+ * Assign `this.phase`, guarding against regression out of the terminal
231
+ * `'Committed'` state — parity with macp-sdk-python's `_set_phase`
232
+ * (`base_projection.py:289-313`). `applyEnvelope` sets `this.phase =
233
+ * 'Committed'` directly on a `Commitment` envelope and returns before
234
+ * `applyMode` is ever called (see `applyEnvelope` above), so this method is
235
+ * never asked to move *into* `'Committed'` — only ever *out of* it, which is
236
+ * exactly the case it exists to reject. Every subclass `applyMode`
237
+ * implementation must call this instead of assigning `this.phase` directly;
238
+ * the one exception is each subclass's own field initializer, which sets
239
+ * the starting phase before any message has been applied and can never
240
+ * observe `'Committed'`.
241
+ */
242
+ protected setPhase(phase: string): void;
229
243
  }
@@ -249,5 +249,23 @@ class BaseProjection {
249
249
  this.anomalies.push(anomaly);
250
250
  logging_1.logger.warn('projection anomaly', anomaly);
251
251
  }
252
+ /**
253
+ * Assign `this.phase`, guarding against regression out of the terminal
254
+ * `'Committed'` state — parity with macp-sdk-python's `_set_phase`
255
+ * (`base_projection.py:289-313`). `applyEnvelope` sets `this.phase =
256
+ * 'Committed'` directly on a `Commitment` envelope and returns before
257
+ * `applyMode` is ever called (see `applyEnvelope` above), so this method is
258
+ * never asked to move *into* `'Committed'` — only ever *out of* it, which is
259
+ * exactly the case it exists to reject. Every subclass `applyMode`
260
+ * implementation must call this instead of assigning `this.phase` directly;
261
+ * the one exception is each subclass's own field initializer, which sets
262
+ * the starting phase before any message has been applied and can never
263
+ * observe `'Committed'`.
264
+ */
265
+ setPhase(phase) {
266
+ if (this.phase === 'Committed')
267
+ return;
268
+ this.phase = phase;
269
+ }
252
270
  }
253
271
  exports.BaseProjection = BaseProjection;
@@ -22,7 +22,15 @@ class DecisionProjection extends base_1.BaseProjection {
22
22
  rationale: record.rationale,
23
23
  sender: envelope.sender,
24
24
  });
25
- this.phase = 'Evaluation';
25
+ // RFC-MACP-0007 §5 rule 6: a runtime MUST reject any Proposal/
26
+ // Evaluation/Objection after the first accepted Vote. A replayed/
27
+ // retried Proposal (same proposalId, a distinct messageId that still
28
+ // passes applyEnvelope's message_id dedup gate) must therefore not
29
+ // rewind `phase` from 'Voting' back to 'Evaluation' (issue #153).
30
+ // Guarding on the initial phase value — mirrors macp-sdk-python's
31
+ // identical `if self.phase == "Proposal": self._set_phase(...)`.
32
+ if (this.phase === 'Proposal')
33
+ this.setPhase('Evaluation');
26
34
  break;
27
35
  }
28
36
  case 'Evaluation': {
@@ -80,20 +88,19 @@ class DecisionProjection extends base_1.BaseProjection {
80
88
  // runtime rejects any session-scoped message once the session is
81
89
  // non-OPEN, so a `Vote` cannot legally follow a `Commitment` in
82
90
  // accepted history. If one reaches here anyway (the caller violated
83
- // the accepted-only contract — see `applyEnvelope`'s docblock), do
84
- // not regress `phase` out of `'Committed'`. Note "phase" itself is
85
- // not a normative MACP term (it appears once, in passing, at
86
- // RFC-MACP-0012 `:211`); this guard is justified by session
87
- // terminality, not by a phase specification. An anomaly would be
88
- // recorded here too, but unlike five of the six sites that were
89
- // "frozen pending cross-SDK agreement" (issue #126/#128, now
90
- // resolved for `duplicate_task_accept`/`settled_handoff` — see
91
- // `base.ts`), this one is a session-terminality phase-regression
92
- // guard, not a settlement discard, and remains genuinely undecided
93
- // per #128's own explicit scoping. No behavior change here.
94
- if (this.phase !== 'Committed') {
95
- this.phase = 'Voting';
96
- }
91
+ // the accepted-only contract — see `applyEnvelope`'s docblock),
92
+ // `setPhase` (see `base.ts`) is what keeps `phase` from regressing
93
+ // out of `'Committed'`. Note "phase" itself is not a normative MACP
94
+ // term (it appears once, in passing, at RFC-MACP-0012 `:211`); this
95
+ // guard is justified by session terminality, not by a phase
96
+ // specification. An anomaly would be recorded here too, but unlike
97
+ // five of the six sites that were "frozen pending cross-SDK
98
+ // agreement" (issue #126/#128, now resolved for
99
+ // `duplicate_task_accept`/`settled_handoff` — see `base.ts`), this
100
+ // one is a session-terminality phase-regression guard, not a
101
+ // settlement discard, and remains genuinely undecided per #128's own
102
+ // explicit scoping. No behavior change here.
103
+ this.setPhase('Voting');
97
104
  break;
98
105
  }
99
106
  default:
@@ -21,7 +21,7 @@ class HandoffProjection extends base_1.BaseProjection {
21
21
  sender: envelope.sender,
22
22
  status: 'offered',
23
23
  });
24
- this.phase = 'OfferPending';
24
+ this.setPhase('OfferPending');
25
25
  break;
26
26
  }
27
27
  case 'HandoffContext': {
@@ -40,7 +40,7 @@ class HandoffProjection extends base_1.BaseProjection {
40
40
  }
41
41
  handoff.contextContentType = record.contentType;
42
42
  if (this.phase === 'OfferPending')
43
- this.phase = 'ContextSharing';
43
+ this.setPhase('ContextSharing');
44
44
  }
45
45
  break;
46
46
  }
@@ -72,7 +72,7 @@ class HandoffProjection extends base_1.BaseProjection {
72
72
  // (proto-registry), so this is always a real boolean once proto 0.1.6
73
73
  // is loaded — `true` marks a runtime synthetic implicit accept.
74
74
  handoff.implicit = record.implicit ?? false;
75
- this.phase = 'Accepted';
75
+ this.setPhase('Accepted');
76
76
  }
77
77
  else {
78
78
  this.recordAnomaly({
@@ -107,7 +107,7 @@ class HandoffProjection extends base_1.BaseProjection {
107
107
  if (handoff.status === 'offered' || handoff.status === 'context_sent') {
108
108
  handoff.status = 'declined';
109
109
  handoff.declinedBy = record.declinedBy;
110
- this.phase = 'Declined';
110
+ this.setPhase('Declined');
111
111
  }
112
112
  else {
113
113
  this.recordAnomaly({
@@ -38,6 +38,19 @@ export interface ProposalRejectRecord {
38
38
  reason?: string;
39
39
  sender: string;
40
40
  }
41
+ /**
42
+ * The three reachable `ProposalRecord.status` values, as a runtime array —
43
+ * cross-SDK contract pinned by the spec repo's `schemas/parity/contract.json`
44
+ * (`proposal_disposition.projection_status_values`, `contract_version`
45
+ * 1.3.0, issue #156). `satisfies readonly ProposalRecord['status'][]` links
46
+ * this array to the type above at compile time, and `_ProposalStatusIsFrozen`
47
+ * below closes the loop in the other direction — together they prove at
48
+ * `npm run check` time, not just in a docblock, that `status` can never hold
49
+ * an acceptance-shaped value like `'accepted'` (see `ProposalRecord.status`'s
50
+ * own docblock above for why acceptance is tracked separately). See
51
+ * `tests/parity/contract.test.ts`.
52
+ */
53
+ export declare const PROPOSAL_STATUS_VALUES: readonly ["open", "rejected", "withdrawn"];
41
54
  export declare class ProposalProjection extends BaseProjection {
42
55
  protected readonly mode = "macp.mode.proposal.v1";
43
56
  readonly proposals: Map<string, ProposalRecord>;
@@ -1,8 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProposalProjection = void 0;
3
+ exports.ProposalProjection = exports.PROPOSAL_STATUS_VALUES = void 0;
4
4
  const constants_1 = require("../constants");
5
5
  const base_1 = require("./base");
6
+ /**
7
+ * The three reachable `ProposalRecord.status` values, as a runtime array —
8
+ * cross-SDK contract pinned by the spec repo's `schemas/parity/contract.json`
9
+ * (`proposal_disposition.projection_status_values`, `contract_version`
10
+ * 1.3.0, issue #156). `satisfies readonly ProposalRecord['status'][]` links
11
+ * this array to the type above at compile time, and `_ProposalStatusIsFrozen`
12
+ * below closes the loop in the other direction — together they prove at
13
+ * `npm run check` time, not just in a docblock, that `status` can never hold
14
+ * an acceptance-shaped value like `'accepted'` (see `ProposalRecord.status`'s
15
+ * own docblock above for why acceptance is tracked separately). See
16
+ * `tests/parity/contract.test.ts`.
17
+ */
18
+ exports.PROPOSAL_STATUS_VALUES = [
19
+ 'open',
20
+ 'rejected',
21
+ 'withdrawn',
22
+ ];
6
23
  class ProposalProjection extends base_1.BaseProjection {
7
24
  mode = constants_1.MODE_PROPOSAL;
8
25
  proposals = new Map();
@@ -86,7 +103,7 @@ class ProposalProjection extends base_1.BaseProjection {
86
103
  const proposal = this.proposals.get(record.proposalId);
87
104
  if (proposal) {
88
105
  proposal.status = 'rejected';
89
- this.phase = 'TerminalRejected';
106
+ this.setPhase('TerminalRejected');
90
107
  }
91
108
  }
92
109
  break;
@@ -15,7 +15,7 @@ class QuorumProjection extends base_1.BaseProjection {
15
15
  case 'ApprovalRequest': {
16
16
  const record = payload;
17
17
  this.requests.set(record.requestId, { ...record, sender: envelope.sender });
18
- this.phase = 'Voting';
18
+ this.setPhase('Voting');
19
19
  break;
20
20
  }
21
21
  case 'Approve': {
@@ -33,12 +33,19 @@ export interface TaskFailRecord {
33
33
  retryable: boolean;
34
34
  sender: string;
35
35
  }
36
+ export interface TaskRejectRecord {
37
+ taskId: string;
38
+ assignee: string;
39
+ reason?: string;
40
+ sender: string;
41
+ }
36
42
  export declare class TaskProjection extends BaseProjection {
37
43
  protected readonly mode = "macp.mode.task.v1";
38
44
  readonly tasks: Map<string, TaskRecord>;
39
45
  readonly updates: TaskUpdateRecord[];
40
46
  readonly completions: TaskCompleteRecord[];
41
47
  readonly failures: TaskFailRecord[];
48
+ readonly rejections: TaskRejectRecord[];
42
49
  phase: 'Pending' | 'Requested' | 'InProgress' | 'Completed' | 'Failed' | 'Committed';
43
50
  /**
44
51
  * The **session-level** active assignment: who currently holds the session's
@@ -9,6 +9,7 @@ class TaskProjection extends base_1.BaseProjection {
9
9
  updates = [];
10
10
  completions = [];
11
11
  failures = [];
12
+ rejections = [];
12
13
  phase = 'Pending';
13
14
  /**
14
15
  * The **session-level** active assignment: who currently holds the session's
@@ -46,7 +47,7 @@ class TaskProjection extends base_1.BaseProjection {
46
47
  progress: 0,
47
48
  sender: envelope.sender,
48
49
  });
49
- this.phase = 'Requested';
50
+ this.setPhase('Requested');
50
51
  break;
51
52
  }
52
53
  case 'TaskAccept': {
@@ -90,7 +91,7 @@ class TaskProjection extends base_1.BaseProjection {
90
91
  task.assignee = record.assignee;
91
92
  task.status = 'accepted';
92
93
  this.activeAssignment = { sender: envelope.sender, taskId: record.taskId };
93
- this.phase = 'InProgress';
94
+ this.setPhase('InProgress');
94
95
  }
95
96
  else {
96
97
  this.recordAnomaly({
@@ -109,6 +110,16 @@ class TaskProjection extends base_1.BaseProjection {
109
110
  }
110
111
  case 'TaskReject': {
111
112
  const record = payload;
113
+ // Issue #151: unconditional audit record, matching this file's own
114
+ // TaskUpdate/TaskComplete/TaskFail convention (and Python's
115
+ // identical unconditional `self.rejections.append(...)`,
116
+ // task.py:186-196) — `sender` comes from the envelope, never the
117
+ // payload, matching the three sibling records. Deliberately does
118
+ // NOT fall back `assignee` to `envelope.sender` the way Python's
119
+ // `p.assignee or envelope.sender` does: TaskComplete/TaskFail below
120
+ // spread the payload's `assignee` as-is with no such fallback, and
121
+ // intra-SDK consistency wins over replicating Python's nuance here.
122
+ this.rejections.push({ ...record, sender: envelope.sender });
112
123
  const task = this.tasks.get(record.taskId);
113
124
  if (task)
114
125
  task.status = 'rejected';
@@ -158,7 +169,7 @@ class TaskProjection extends base_1.BaseProjection {
158
169
  if (task) {
159
170
  task.status = 'completed';
160
171
  task.progress = 1;
161
- this.phase = 'Completed';
172
+ this.setPhase('Completed');
162
173
  }
163
174
  break;
164
175
  }
@@ -168,7 +179,7 @@ class TaskProjection extends base_1.BaseProjection {
168
179
  const task = this.tasks.get(record.taskId);
169
180
  if (task) {
170
181
  task.status = 'failed';
171
- this.phase = 'Failed';
182
+ this.setPhase('Failed');
172
183
  }
173
184
  break;
174
185
  }
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * Kept in sync with package.json by release-please (see the
5
5
  * `x-release-please-version` annotation).
6
6
  */
7
- export declare const VERSION = "0.14.0";
7
+ export declare const VERSION = "0.14.1";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@ exports.VERSION = void 0;
7
7
  * Kept in sync with package.json by release-please (see the
8
8
  * `x-release-please-version` annotation).
9
9
  */
10
- exports.VERSION = '0.14.0'; // x-release-please-version
10
+ exports.VERSION = '0.14.1'; // x-release-please-version
package/dist/watchers.js CHANGED
@@ -130,7 +130,7 @@ class ModeRegistryWatcher {
130
130
  const result = await gen.next();
131
131
  await gen.return(undefined);
132
132
  if (result.done)
133
- throw new Error('stream ended before receiving a change');
133
+ throw new errors_1.MacpTransportError('stream ended before receiving a change');
134
134
  return result.value;
135
135
  }
136
136
  }
@@ -159,7 +159,7 @@ class RootsWatcher {
159
159
  const result = await gen.next();
160
160
  await gen.return(undefined);
161
161
  if (result.done)
162
- throw new Error('stream ended before receiving a change');
162
+ throw new errors_1.MacpTransportError('stream ended before receiving a change');
163
163
  return result.value;
164
164
  }
165
165
  }
@@ -202,7 +202,7 @@ class SignalWatcher {
202
202
  const result = await gen.next();
203
203
  await gen.return(undefined);
204
204
  if (result.done)
205
- throw new Error('stream ended before receiving a signal');
205
+ throw new errors_1.MacpTransportError('stream ended before receiving a signal');
206
206
  return result.value;
207
207
  }
208
208
  }
@@ -231,7 +231,7 @@ class PolicyWatcher {
231
231
  const result = await gen.next();
232
232
  await gen.return(undefined);
233
233
  if (result.done)
234
- throw new Error('stream ended before receiving a policy change');
234
+ throw new errors_1.MacpTransportError('stream ended before receiving a policy change');
235
235
  return result.value;
236
236
  }
237
237
  }
@@ -270,7 +270,7 @@ class SessionLifecycleWatcher {
270
270
  const result = await gen.next();
271
271
  await gen.return(undefined);
272
272
  if (result.done)
273
- throw new Error('stream ended before receiving a session lifecycle event');
273
+ throw new errors_1.MacpTransportError('stream ended before receiving a session lifecycle event');
274
274
  return result.value;
275
275
  }
276
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macp-sdk-typescript",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",