macp-sdk-typescript 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.13.0...v0.14.0) (2026-10-01)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * **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.
13
+ * **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.
14
+
15
+ ### Bug Fixes
16
+
17
+ * **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))
18
+ * **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))
19
+
7
20
  ## [0.13.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.12.0...v0.13.0) (2026-09-30)
8
21
 
9
22
 
@@ -131,6 +144,30 @@ project uses [Semantic Versioning](https://semver.org/).
131
144
  still only fails server-side). A caller that genuinely needs one
132
145
  credential reused across multiple senders should use
133
146
  `Auth.bearer(token, senderHint)` (the legacy two-arg form) instead.
147
+ * **projections:** `ProposalRecord.status`'s type no longer accepts
148
+ `'accepted'` (issue #146). No code path has ever assigned it — acceptance
149
+ is tracked separately via `accepts`/`isAccepted()`, since it's a
150
+ per-sender, supersedable relation (RFC-MACP-0008 §5 rule 5), not a
151
+ per-proposal fact a scalar field could hold. A TypeScript consumer
152
+ comparing `status === 'accepted'` or switching on it now gets a
153
+ compile-time `TS2367`/`TS2678`/`TS2322` instead of silently-dead code; a
154
+ plain-JS (or `as any`-cast) caller sees no behavior change, since the
155
+ comparison always evaluated `false`.
156
+ * **agent:** `fromBootstrap()`'s `encodeExtensions()` now base64-decodes a
157
+ `session_start.extensions` string value (falling back to raw UTF-8 for a
158
+ non-base64 string), instead of `JSON.stringify`-ing it, per RFC-MACP-0001
159
+ §10.3 (a protobuf `bytes` field's canonical JSON representation is
160
+ base64) — the wire-level `extensions` map is `map<string, bytes>` (issue
161
+ #139). This reverses the UTF-8-JSON-encoding behavior documented at the
162
+ `0.2.3` entry above (SDK-TS-1): that release's choice silently put wire
163
+ bytes on the connection that a conforming peer (including this SDK's own
164
+ `macp-sdk-python` counterpart) cannot decode, even for the canonical
165
+ example value. A number, boolean, plain object, array, or `null`
166
+ extension value — previously silently JSON-encoded — now throws at
167
+ bootstrap construction instead, naming the offending key; so does a
168
+ present-but-non-object `extensions` map itself. `Buffer`/`Uint8Array`
169
+ values are unaffected (same passthrough as before, for TS-only
170
+ in-process callers — never reachable via a JSON bootstrap file).
134
171
 
135
172
  ### Features
136
173
 
@@ -191,6 +228,12 @@ project uses [Semantic Versioning](https://semver.org/).
191
228
  sites. `DecisionProjection`'s late-`Vote`-after-`Commitment` site remains
192
229
  the one still-open, undecided case (per #128's own explicit scoping) —
193
230
  unaffected by this change.
231
+ - **`encodeExtensions()` is now exported from `macp-sdk-typescript/agent`**
232
+ (issue #139), so a caller constructing a bootstrap `extensions` map
233
+ programmatically can validate/encode a value the same way
234
+ `fromBootstrap()` does, and so it's directly unit-testable (a
235
+ `Buffer`/`Uint8Array` value can't round-trip through a JSON bootstrap
236
+ file otherwise).
194
237
 
195
238
  ### Changed
196
239
 
@@ -466,6 +509,11 @@ project uses [Semantic Versioning](https://semver.org/).
466
509
  site — `taskId`, `title`, `requestId`, `proposalId`, `option`, `handoffId`,
467
510
  `targetParticipant`, `modeVersion`, `configurationVersion` — is unaffected
468
511
  and still rejects an empty value.
512
+ - **`ProposalRecord.status`'s declared type no longer lies about
513
+ `'accepted'`** (issue #146). The union included `'accepted'` since this
514
+ type was first written, but nothing has ever assigned it — see the
515
+ `### ⚠ BREAKING CHANGES` entry above for why, and why that correction is a
516
+ breaking (not purely additive) change.
469
517
 
470
518
  ### Documentation
471
519
 
@@ -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;
@@ -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
@@ -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;
@@ -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,
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.0";
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.0'; // x-release-please-version
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.0",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",