macp-sdk-typescript 0.13.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,88 @@ 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
+
76
+ ## [0.14.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.13.0...v0.14.0) (2026-10-01)
77
+
78
+
79
+ ### ⚠ BREAKING CHANGES
80
+
81
+ * **agent:** encodeExtensions() (src/agent/runner.ts) no longer JSON-stringifies a session_start.extensions value. A string value is now base64-decoded (falling back to raw UTF-8 if it isn't valid base64); a number, boolean, plain object, array, or null value now throws instead of being silently JSON-encoded, as does a present-but-non-object extensions map itself. Buffer/Uint8Array values are unaffected. This reverses the UTF-8-JSON-encoding behavior documented in the 0.2.3 (SDK-TS-1) CHANGELOG entry, which silently put wire bytes on the connection that a conforming peer cannot decode.
82
+ * **projections:** ProposalRecord.status's type no longer accepts 'accepted'. A TypeScript consumer comparing status === 'accepted' or switching on it now gets a compile-time TS2367/TS2678/TS2322 instead of silently-dead code; a plain-JS (or `as any`-cast) caller sees no behavior change, since the comparison always evaluated false.
83
+
84
+ ### Bug Fixes
85
+
86
+ * **agent:** encodeExtensions() base64-decodes extensions values ([#149](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/149)) ([72c6ea4](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/72c6ea484e1dd5e75dbfc0bd12cbed787eeeacbd))
87
+ * **projections:** ProposalRecord.status no longer accepts 'accepted' ([#147](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/147)) ([c5142d7](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/c5142d7c1ccd37b128549e512e9d5c6436899e30))
88
+
7
89
  ## [0.13.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.12.0...v0.13.0) (2026-09-30)
8
90
 
9
91
 
@@ -131,6 +213,30 @@ project uses [Semantic Versioning](https://semver.org/).
131
213
  still only fails server-side). A caller that genuinely needs one
132
214
  credential reused across multiple senders should use
133
215
  `Auth.bearer(token, senderHint)` (the legacy two-arg form) instead.
216
+ * **projections:** `ProposalRecord.status`'s type no longer accepts
217
+ `'accepted'` (issue #146). No code path has ever assigned it — acceptance
218
+ is tracked separately via `accepts`/`isAccepted()`, since it's a
219
+ per-sender, supersedable relation (RFC-MACP-0008 §5 rule 5), not a
220
+ per-proposal fact a scalar field could hold. A TypeScript consumer
221
+ comparing `status === 'accepted'` or switching on it now gets a
222
+ compile-time `TS2367`/`TS2678`/`TS2322` instead of silently-dead code; a
223
+ plain-JS (or `as any`-cast) caller sees no behavior change, since the
224
+ comparison always evaluated `false`.
225
+ * **agent:** `fromBootstrap()`'s `encodeExtensions()` now base64-decodes a
226
+ `session_start.extensions` string value (falling back to raw UTF-8 for a
227
+ non-base64 string), instead of `JSON.stringify`-ing it, per RFC-MACP-0001
228
+ §10.3 (a protobuf `bytes` field's canonical JSON representation is
229
+ base64) — the wire-level `extensions` map is `map<string, bytes>` (issue
230
+ #139). This reverses the UTF-8-JSON-encoding behavior documented at the
231
+ `0.2.3` entry above (SDK-TS-1): that release's choice silently put wire
232
+ bytes on the connection that a conforming peer (including this SDK's own
233
+ `macp-sdk-python` counterpart) cannot decode, even for the canonical
234
+ example value. A number, boolean, plain object, array, or `null`
235
+ extension value — previously silently JSON-encoded — now throws at
236
+ bootstrap construction instead, naming the offending key; so does a
237
+ present-but-non-object `extensions` map itself. `Buffer`/`Uint8Array`
238
+ values are unaffected (same passthrough as before, for TS-only
239
+ in-process callers — never reachable via a JSON bootstrap file).
134
240
 
135
241
  ### Features
136
242
 
@@ -191,6 +297,12 @@ project uses [Semantic Versioning](https://semver.org/).
191
297
  sites. `DecisionProjection`'s late-`Vote`-after-`Commitment` site remains
192
298
  the one still-open, undecided case (per #128's own explicit scoping) —
193
299
  unaffected by this change.
300
+ - **`encodeExtensions()` is now exported from `macp-sdk-typescript/agent`**
301
+ (issue #139), so a caller constructing a bootstrap `extensions` map
302
+ programmatically can validate/encode a value the same way
303
+ `fromBootstrap()` does, and so it's directly unit-testable (a
304
+ `Buffer`/`Uint8Array` value can't round-trip through a JSON bootstrap
305
+ file otherwise).
194
306
 
195
307
  ### Changed
196
308
 
@@ -466,6 +578,11 @@ project uses [Semantic Versioning](https://semver.org/).
466
578
  site — `taskId`, `title`, `requestId`, `proposalId`, `option`, `handoffId`,
467
579
  `targetParticipant`, `modeVersion`, `configurationVersion` — is unaffected
468
580
  and still rejects an empty value.
581
+ - **`ProposalRecord.status`'s declared type no longer lies about
582
+ `'accepted'`** (issue #146). The union included `'accepted'` since this
583
+ type was first written, but nothing has ever assigned it — see the
584
+ `### ⚠ BREAKING CHANGES` entry above for why, and why that correction is a
585
+ breaking (not purely additive) change.
469
586
 
470
587
  ### Documentation
471
588
 
@@ -24,7 +24,7 @@ export interface BootstrapPayload {
24
24
  policy_version?: string;
25
25
  context?: Record<string, unknown>;
26
26
  context_id?: string;
27
- extensions?: Record<string, unknown>;
27
+ extensions?: Record<string, string | Buffer | Uint8Array>;
28
28
  roots?: Array<{
29
29
  uri: string;
30
30
  name?: string;
@@ -50,3 +50,25 @@ export interface BootstrapPayload {
50
50
  };
51
51
  }
52
52
  export declare function fromBootstrap(bootstrapPath?: string): Participant;
53
+ /**
54
+ * Coerce a bootstrap `session_start.extensions` map into the
55
+ * `Record<string, Buffer>` form the SessionStart envelope expects
56
+ * (`map<string, bytes>` on the wire).
57
+ *
58
+ * Per RFC-MACP-0001 §10.3, a protobuf `bytes` field's canonical JSON
59
+ * representation is a base64 string, so a `string` value is base64-decoded
60
+ * first (strict shape check via `BASE64_PATTERN`, not Node's lenient
61
+ * decoder); a string that isn't valid base64 falls back to raw UTF-8 bytes.
62
+ * This base64-first heuristic — and the resulting ambiguity for a plain
63
+ * string that happens to also be valid base64 — mirrors
64
+ * `macp-sdk-python`'s `_decode_extensions` on purpose, to stay
65
+ * byte-for-byte interoperable; it is a known, tracked wart, not fixed here.
66
+ * `Buffer`/`Uint8Array` values pass through unchanged, for TS-only
67
+ * in-process callers that already hold raw bytes (never reachable via a
68
+ * JSON bootstrap file). Anything else — including the `extensions`
69
+ * container itself being a non-object (string/number/boolean/array) —
70
+ * throws, naming the offending key and/or type, so a malformed bootstrap
71
+ * fails loudly at construction instead of silently corrupting wire bytes a
72
+ * peer can't decode (issue #139).
73
+ */
74
+ export declare function encodeExtensions(extensions: Record<string, string | Buffer | Uint8Array> | undefined): Record<string, Buffer> | undefined;
@@ -34,11 +34,13 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.fromBootstrap = fromBootstrap;
37
+ exports.encodeExtensions = encodeExtensions;
37
38
  const fs = __importStar(require("node:fs"));
38
39
  const path = __importStar(require("node:path"));
39
40
  const auth_1 = require("../auth");
40
41
  const client_1 = require("../client");
41
42
  const constants_1 = require("../constants");
43
+ const logging_1 = require("../logging");
42
44
  const participant_1 = require("./participant");
43
45
  function fromBootstrap(bootstrapPath) {
44
46
  const resolvedPath = bootstrapPath ?? process.env.MACP_BOOTSTRAP_FILE;
@@ -111,17 +113,53 @@ function fromBootstrap(bootstrapPath) {
111
113
  };
112
114
  return new participant_1.Participant(config);
113
115
  }
116
+ // Matches a syntactically valid base64 string (correct alphabet, correct
117
+ // padding) — a cheap stand-in for Python's `base64.b64decode(value,
118
+ // validate=True)` so a plainly non-base64 string (e.g. "pack:example-001")
119
+ // is rejected up front rather than silently mis-decoded by Node's lenient
120
+ // `Buffer.from(str, 'base64')`, which ignores invalid characters instead of
121
+ // raising. Matches the empty string too (decodes to an empty buffer either
122
+ // way, so which branch handles it is immaterial).
123
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
124
+ function tryDecodeBase64(value) {
125
+ if (!BASE64_PATTERN.test(value))
126
+ return undefined;
127
+ return Buffer.from(value, 'base64');
128
+ }
129
+ function describeExtensionsValueType(value) {
130
+ if (value === null)
131
+ return 'null';
132
+ if (Array.isArray(value))
133
+ return 'array';
134
+ return typeof value;
135
+ }
114
136
  /**
115
- * Serialise a JSON-native extensions map (as delivered in the bootstrap
116
- * payload) into the `Record<string, Buffer>` form the SessionStart envelope
117
- * expects. Each value is encoded as UTF-8 JSON so arbitrary structures
118
- * round-trip through the protobuf `bytes` field. `Buffer` / `Uint8Array`
119
- * values are passed through untouched so callers that already hold raw bytes
120
- * don't pay a double-encode.
137
+ * Coerce a bootstrap `session_start.extensions` map into the
138
+ * `Record<string, Buffer>` form the SessionStart envelope expects
139
+ * (`map<string, bytes>` on the wire).
140
+ *
141
+ * Per RFC-MACP-0001 §10.3, a protobuf `bytes` field's canonical JSON
142
+ * representation is a base64 string, so a `string` value is base64-decoded
143
+ * first (strict shape check via `BASE64_PATTERN`, not Node's lenient
144
+ * decoder); a string that isn't valid base64 falls back to raw UTF-8 bytes.
145
+ * This base64-first heuristic — and the resulting ambiguity for a plain
146
+ * string that happens to also be valid base64 — mirrors
147
+ * `macp-sdk-python`'s `_decode_extensions` on purpose, to stay
148
+ * byte-for-byte interoperable; it is a known, tracked wart, not fixed here.
149
+ * `Buffer`/`Uint8Array` values pass through unchanged, for TS-only
150
+ * in-process callers that already hold raw bytes (never reachable via a
151
+ * JSON bootstrap file). Anything else — including the `extensions`
152
+ * container itself being a non-object (string/number/boolean/array) —
153
+ * throws, naming the offending key and/or type, so a malformed bootstrap
154
+ * fails loudly at construction instead of silently corrupting wire bytes a
155
+ * peer can't decode (issue #139).
121
156
  */
122
157
  function encodeExtensions(extensions) {
123
- if (!extensions)
158
+ if (extensions === undefined || extensions === null)
124
159
  return undefined;
160
+ if (typeof extensions !== 'object' || Array.isArray(extensions)) {
161
+ throw new Error(`bootstrap session_start.extensions must be an object, got ${describeExtensionsValueType(extensions)}`);
162
+ }
125
163
  const out = {};
126
164
  for (const [key, value] of Object.entries(extensions)) {
127
165
  if (Buffer.isBuffer(value)) {
@@ -130,8 +168,19 @@ function encodeExtensions(extensions) {
130
168
  else if (value instanceof Uint8Array) {
131
169
  out[key] = Buffer.from(value);
132
170
  }
171
+ else if (typeof value === 'string') {
172
+ const decoded = tryDecodeBase64(value);
173
+ if (decoded !== undefined) {
174
+ logging_1.logger.debug(`[fromBootstrap] extensions["${key}"] decoded as base64`);
175
+ out[key] = decoded;
176
+ }
177
+ else {
178
+ logging_1.logger.debug(`[fromBootstrap] extensions["${key}"] is not valid base64; falling back to raw UTF-8`);
179
+ out[key] = Buffer.from(value, 'utf8');
180
+ }
181
+ }
133
182
  else {
134
- out[key] = Buffer.from(JSON.stringify(value), 'utf8');
183
+ throw new Error(`bootstrap extensions["${key}"] must be a base64-encoded string (or Buffer/Uint8Array), got ${describeExtensionsValueType(value)}`);
135
184
  }
136
185
  }
137
186
  return out;
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
  }
@@ -14,9 +14,12 @@ export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot' | 'dup
14
14
  * this proposal (RFC-MACP-0007 §5.3), or a second distinct ballot across
15
15
  * `Approve`/`Reject`/`Abstain` from this sender for this request
16
16
  * (RFC-MACP-0011 §5 rule 3), was observed and discarded — the first stands.
17
- * (RFC-MACP-0011 §5 rule 3 caps *how many* ballots and is silent on *which of
18
- * two* stands; first-ballot-wins here is parity with RFC-MACP-0007 §5.3 plus
19
- * runtime-enforced behaviour.)
17
+ * (RFC-MACP-0011 §5 rule 3 states "the first accepted ballot stands"
18
+ * directly, since the rule-3 hardening in spec PR
19
+ * multiagentcoordinationprotocol#85 — before that, this SDK's
20
+ * first-ballot-wins behaviour was an inference from parity with
21
+ * RFC-MACP-0007 §5.3 plus runtime-enforced behaviour, not a direct
22
+ * RFC-0011 citation.)
20
23
  *
21
24
  * **Deliberately narrow claim — do not overclaim "this transcript violates
22
25
  * the spec".** A projection cannot tell a genuinely non-conforming source
@@ -223,4 +226,18 @@ export declare abstract class BaseProjection {
223
226
  * `MACP_LOG_LEVEL` environment variable.
224
227
  */
225
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;
226
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({
@@ -8,7 +8,24 @@ export interface ProposalRecord {
8
8
  tags?: string[];
9
9
  sender: string;
10
10
  supersedes?: string;
11
- status: 'open' | 'accepted' | 'rejected' | 'withdrawn';
11
+ /**
12
+ * Reachable values only (issue #146). An `Accept` is recorded on the
13
+ * projection's `accepts` list (and surfaced via `acceptedProposal()` /
14
+ * `isAccepted()`), never on this field — no code path assigns `'accepted'`
15
+ * here. A non-terminal `Reject` likewise leaves this `'open'`; only a
16
+ * terminal `Reject` sets `'rejected'`.
17
+ *
18
+ * That is by design, not an omission: acceptance is a per-sender,
19
+ * supersedable relation (RFC-MACP-0008 §5 rule 5), not a per-proposal
20
+ * fact, so a scalar field here cannot hold it — "alice accepts p2 while
21
+ * bob still accepts p1" is a legal state. This mirrors the runtime, whose
22
+ * `ProposalDisposition` is `{Live, Withdrawn}` with acceptance in a
23
+ * separate `accepts` map, and `macp-sdk-python`'s own `status: str`
24
+ * (documented the same way after its issue #112). `task.ts`/`handoff.ts`
25
+ * do set `'accepted'`-equivalent statuses because their acceptance is one
26
+ * actor claiming one slot — this mode's isn't. See `docs/modes/proposal.md`.
27
+ */
28
+ status: 'open' | 'rejected' | 'withdrawn';
12
29
  }
13
30
  export interface ProposalAcceptRecord {
14
31
  proposalId: string;
@@ -21,6 +38,19 @@ export interface ProposalRejectRecord {
21
38
  reason?: string;
22
39
  sender: string;
23
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"];
24
54
  export declare class ProposalProjection extends BaseProjection {
25
55
  protected readonly mode = "macp.mode.proposal.v1";
26
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': {
@@ -54,21 +54,24 @@ class QuorumProjection extends base_1.BaseProjection {
54
54
  const senderMap = this.ballots.get(requestId) ?? new Map();
55
55
  const kept = senderMap.get(envelope.sender);
56
56
  if (kept !== undefined) {
57
- // RFC-MACP-0011 §5 rule 3 (`:67`): participation is MAY (a participant need
58
- // not cast a ballot), but the cap of one ballot per participant ACROSS
59
- // Approve, Reject, and Abstain is enforced under §5's opening sentence,
60
- // "Implementations MUST enforce the following:" (`:63`). Do NOT phrase this
61
- // as "a participant MUST cast at most one ballot" — RFC-0011 puts the MUST
62
- // on the implementation, not the participant (contrast RFC-MACP-0007 §5.3,
63
- // which puts it directly on the participant; same obligation, different
64
- // addressee). A later ballot of a *different* type is still a duplicate,
65
- // not a change of vote, so this guard is keyed on the sender ALONE within
66
- // the request — never on sender + vote. macp-runtime enforces this
67
- // identically in all three arms (quorum.rs:164/184/204).
68
- // Keeping the sender's FIRST ballot (rather than the last) is not stated
69
- // by RFC-0011 either; it is parity with RFC-MACP-0007 §5 item 3's
70
- // explicit first-stands rule for `Vote`, plus macp-runtime's enforced
71
- // first-wins behaviour.
57
+ // RFC-MACP-0011 §5 rule 3 (`:69`, hardened by spec PR
58
+ // multiagentcoordinationprotocol#85): "Each eligible participant MUST
59
+ // cast at most one ballot per request_id... A runtime MUST reject a
60
+ // second ballot from the same sender for the same request_id,
61
+ // regardless of the type of either ballot; the first accepted ballot
62
+ // stands." Participation itself is still MAY (a participant need not
63
+ // ballot at all) — only the one-per-participant cap and first-wins
64
+ // outcome are MUST. A later ballot of a *different* type is still a
65
+ // duplicate, not a change of vote, so this guard is keyed on the
66
+ // sender ALONE within the request — never on sender + vote.
67
+ // macp-runtime enforces this identically in all three arms
68
+ // (quorum.rs:164/184/204).
69
+ //
70
+ // Keeping the sender's FIRST ballot is now a direct rule-3 citation
71
+ // (above), not an inference — before PR #85 hardened the rule, this
72
+ // comment relied on parity with RFC-MACP-0007 §5 item 3's first-stands
73
+ // rule for `Vote` plus macp-runtime's observed behavior, since RFC-0011
74
+ // capped *how many* ballots without yet stating *which* stands.
72
75
  this.recordAnomaly({
73
76
  kind: 'duplicate_ballot',
74
77
  mode: envelope.mode,
@@ -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.13.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.13.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.13.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",