macp-sdk-typescript 0.14.1 → 0.15.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,14 +4,21 @@ 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)
7
+ ## [0.15.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.14.1...v0.15.0) (2026-10-17)
8
+
9
+
10
+ ### Features
11
+
12
+ * **commitment-hash:** RFC-MACP-0013 §6.1 verifyCommitmentHash + verify vectors 006–010 ([#170](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/170)) ([a39421c](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/a39421cfb2908731b1470ff787b0fffcacfd6299))
13
+ * **errors:** export DENY_* constants and KNOWN_DENY_CODES; keep message-less denials ([#169](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/169)) ([fef7335](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/fef733589427dd185fe445024e330c8c10d4c864)), closes [#168](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/168)
14
+ * **errors:** runtime 0.9.0 registry error codes and structured policy denials ([#164](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/164)) ([d37ad7f](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/d37ad7ff799e94b66271a08e7d18774b115d1c4c))
8
15
 
9
16
 
10
17
  ### Bug Fixes
11
18
 
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))
19
+ * **validation:** cap base64url session ids at 128 bytes ([#167](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/167)) ([bb60588](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/bb60588f67edcb200e9f109491e3a262232a8a11))
13
20
 
14
- ## [Unreleased]
21
+ ## [0.14.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.14.0...v0.14.1) (2026-10-03)
15
22
 
16
23
  ### Added
17
24
 
@@ -72,6 +79,7 @@ project uses [Semantic Versioning](https://semver.org/).
72
79
  covers a cleanly-ended watch stream alongside every other SDK failure.
73
80
  Message strings are unchanged byte-for-byte; `error.code` remains
74
81
  `undefined` on all 5 (locally raised, no gRPC status to attach).
82
+ - **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))
75
83
 
76
84
  ## [0.14.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.13.0...v0.14.0) (2026-10-01)
77
85
 
package/README.md CHANGED
@@ -43,7 +43,9 @@ await client.initialize();
43
43
  const session = new DecisionSession(client);
44
44
  await session.start({
45
45
  intent: 'pick a deployment strategy',
46
- participants: ['alice', 'bob'],
46
+ // 'coordinator' (this client's own identity) sends propose()/commit() below with no
47
+ // sender override, so it must be a declared participant too.
48
+ participants: ['coordinator', 'alice', 'bob'],
47
49
  ttlMs: 60_000,
48
50
  });
49
51
 
@@ -108,7 +110,10 @@ Structured decision with proposals, evaluations, objections, and votes.
108
110
  import { DecisionSession } from 'macp-sdk-typescript';
109
111
 
110
112
  const session = new DecisionSession(client);
111
- await session.start({ intent: '...', participants: ['alice'], ttlMs: 60_000 });
113
+ // 'coordinator' (this client's own identity) sends propose()/vote()/commit() below,
114
+ // so it must be a declared participant — Decision mode's default governance policy
115
+ // rejects a mode action from a non-participant sender with FORBIDDEN.
116
+ await session.start({ intent: '...', participants: ['coordinator', 'alice'], ttlMs: 60_000 });
112
117
  await session.propose({ proposalId: 'p1', option: 'A', rationale: '...' });
113
118
  await session.evaluate({ proposalId: 'p1', recommendation: 'approve', confidence: 0.9 });
114
119
  await session.raiseObjection({ proposalId: 'p1', reason: 'risk', severity: 'high' });
@@ -118,7 +123,7 @@ await session.commit({ action: 'decided', authorityScope: 'team', reason: '...'
118
123
  // Projection queries
119
124
  session.projection.voteTotals(); // { p1: 1 }
120
125
  session.projection.majorityWinner(); // 'p1'
121
- session.projection.hasBlockingObjection('p1'); // true (severity: high)
126
+ session.projection.hasBlockingObjection('p1'); // false (only severity 'critical' blocks)
122
127
  ```
123
128
 
124
129
  ### Proposal Mode
@@ -126,13 +131,17 @@ session.projection.hasBlockingObjection('p1'); // true (severity: high)
126
131
  Proposal and counterproposal negotiation.
127
132
 
128
133
  ```typescript
129
- import { ProposalSession } from 'macp-sdk-typescript';
134
+ import { Auth, ProposalSession } from 'macp-sdk-typescript';
130
135
 
131
136
  const session = new ProposalSession(client);
132
- await session.start({ intent: '...', participants: ['bob'], ttlMs: 60_000 });
137
+ // 'coordinator' sends propose() below, so it must be a declared participant (same
138
+ // FORBIDDEN rule as Decision mode). Proposal mode's default policy also requires
139
+ // *every* participant to accept() before commit() — not just the non-proposing ones.
140
+ await session.start({ intent: '...', participants: ['coordinator', 'bob'], ttlMs: 60_000 });
133
141
  await session.propose({ proposalId: 'p1', title: 'Plan A', summary: '...' });
134
142
  await session.counterPropose({ proposalId: 'p2', supersedesProposalId: 'p1', title: 'Plan B' });
135
143
  await session.accept({ proposalId: 'p2', reason: 'better' });
144
+ await session.accept({ proposalId: 'p2', reason: 'bob agrees', sender: 'bob', auth: Auth.devAgent('bob') });
136
145
  await session.commit({ action: 'proposal.accepted', authorityScope: 'team', reason: '...' });
137
146
 
138
147
  // Projection queries
@@ -146,14 +155,18 @@ session.projection.isTerminallyRejected('p1'); // false
146
155
  Bounded task delegation.
147
156
 
148
157
  ```typescript
149
- import { TaskSession } from 'macp-sdk-typescript';
158
+ import { Auth, TaskSession } from 'macp-sdk-typescript';
150
159
 
151
160
  const session = new TaskSession(client);
152
161
  await session.start({ intent: '...', participants: ['worker'], ttlMs: 120_000 });
153
162
  await session.requestTask({ taskId: 't1', title: 'Build feature', instructions: '...' });
154
- await session.acceptTask({ taskId: 't1', assignee: 'worker' });
155
- await session.updateTask({ taskId: 't1', status: 'working', progress: 0.5, message: 'halfway' });
156
- await session.completeTask({ taskId: 't1', assignee: 'worker', summary: 'done' });
163
+ // The coordinator requests and commits, but every task-side action must be sent by the
164
+ // assignee itself: Task mode's default policy rejects an acceptTask/updateTask/
165
+ // completeTask from any other sender with FORBIDDEN.
166
+ const asWorker = { sender: 'worker', auth: Auth.devAgent('worker') };
167
+ await session.acceptTask({ taskId: 't1', assignee: 'worker', ...asWorker });
168
+ await session.updateTask({ taskId: 't1', status: 'working', progress: 0.5, message: 'halfway', ...asWorker });
169
+ await session.completeTask({ taskId: 't1', assignee: 'worker', summary: 'done', ...asWorker });
157
170
  await session.commit({ action: 'task.completed', authorityScope: 'lead', reason: '...' });
158
171
 
159
172
  // Projection queries
@@ -167,13 +180,22 @@ session.projection.activeTasks(); // []
167
180
  Responsibility transfer between participants.
168
181
 
169
182
  ```typescript
170
- import { HandoffSession } from 'macp-sdk-typescript';
183
+ import { Auth, HandoffSession } from 'macp-sdk-typescript';
171
184
 
172
185
  const session = new HandoffSession(client);
173
- await session.start({ intent: '...', participants: ['bob'], ttlMs: 60_000 });
186
+ // Handoff's SessionStart itself requires >= 2 participants, AND the initiator
187
+ // ('coordinator', this client's own identity) must be one of them.
188
+ await session.start({ intent: '...', participants: ['coordinator', 'bob'], ttlMs: 60_000 });
174
189
  await session.offer({ handoffId: 'h1', targetParticipant: 'bob', scope: 'frontend' });
175
190
  await session.addContext({ handoffId: 'h1', contentType: 'application/json', context: buf });
176
- await session.acceptHandoff({ handoffId: 'h1', acceptedBy: 'bob' });
191
+ // Only the offer's targetParticipant may accept — sender must be 'bob', not the
192
+ // session's own coordinator identity.
193
+ await session.acceptHandoff({
194
+ handoffId: 'h1',
195
+ acceptedBy: 'bob',
196
+ sender: 'bob',
197
+ auth: Auth.devAgent('bob'),
198
+ });
177
199
  await session.commit({ action: 'handoff.accepted', authorityScope: 'team', reason: '...' });
178
200
 
179
201
  // Projection queries
@@ -186,19 +208,24 @@ session.projection.pendingHandoffs(); // []
186
208
  Threshold-based approval voting.
187
209
 
188
210
  ```typescript
189
- import { QuorumSession } from 'macp-sdk-typescript';
211
+ import { Auth, QuorumSession } from 'macp-sdk-typescript';
190
212
 
191
213
  const session = new QuorumSession(client);
192
214
  await session.start({ intent: '...', participants: ['alice', 'bob', 'carol'], ttlMs: 60_000 });
193
215
  await session.requestApproval({ requestId: 'r1', action: 'deploy', summary: '...', requiredApprovals: 2 });
194
- await session.approve({ requestId: 'r1', reason: 'ok' });
216
+ // Ballots must come from a declared participant ('coordinator' is not one here), so each
217
+ // approve() needs its own sender. commit() additionally requires the threshold to be met —
218
+ // with requiredApprovals: 2, a single approval makes the runtime reject the Commitment
219
+ // with INVALID_ENVELOPE.
220
+ await session.approve({ requestId: 'r1', reason: 'ok', sender: 'alice', auth: Auth.devAgent('alice') });
221
+ await session.approve({ requestId: 'r1', reason: 'ok', sender: 'bob', auth: Auth.devAgent('bob') });
195
222
  await session.commit({ action: 'quorum.approved', authorityScope: 'ops', reason: '...' });
196
223
 
197
224
  // Projection queries
198
- session.projection.hasQuorum('r1'); // true/false
199
- session.projection.approvalCount('r1'); // number
200
- session.projection.remainingVotesNeeded('r1'); // number
201
- session.projection.votedSenders('r1'); // string[]
225
+ session.projection.hasQuorum('r1'); // true
226
+ session.projection.approvalCount('r1'); // 2
227
+ session.projection.remainingVotesNeeded('r1'); // 0
228
+ session.projection.votedSenders('r1'); // ['alice', 'bob']
202
229
  ```
203
230
 
204
231
  ## Agent Framework
@@ -412,12 +439,15 @@ npm run format # Prettier
412
439
  npm test # Run unit + conformance tests
413
440
  npm run test:watch # Watch mode
414
441
  npm run test:coverage # With coverage (CI enforces thresholds from vitest.config.ts)
415
- npm run test:integration # Integration tests (requires Docker runtime; not run in CI)
442
+ npm run test:integration # Integration tests (requires Docker runtime)
416
443
  ```
417
444
 
418
445
  CI (GitHub Actions) runs type-check, lint, format check, the coverage-gated
419
446
  test suite, and the build on Node 22 and 24 for every push and pull
420
- request, and posts a coverage summary comment on PRs.
447
+ request, and posts a coverage summary comment on PRs. A separate workflow
448
+ (`.github/workflows/integration.yml`) runs `npm run test:integration` against a live
449
+ runtime service container on every pull request too — an example that no longer runs
450
+ cleanly fails that workflow, not just a local check.
421
451
 
422
452
  ### Integration Tests
423
453
 
package/dist/client.d.ts CHANGED
@@ -29,10 +29,21 @@ interface MacpClientOptions {
29
29
  * unknown codes. Used to populate {@link MacpTransportError.code}.
30
30
  */
31
31
  export declare function grpcStatusName(code: unknown): string | undefined;
32
- export type InlineErrorCallback = (error: {
32
+ /**
33
+ * An inline `MACPError` frame on a `StreamSession` stream. Since runtime 0.9.0
34
+ * `code` is the error-codes registry code (`INVALID_ENVELOPE`,
35
+ * `POLICY_DENIED`, ...) — before, it held the status message text — and
36
+ * `messageId`/`details` identify the rejected envelope and carry the same
37
+ * structured details a unary `Ack` does (decode with `parseErrorDetails`).
38
+ */
39
+ export interface InlineStreamError {
33
40
  code?: string;
34
41
  message?: string;
35
- }) => void;
42
+ sessionId?: string;
43
+ messageId?: string;
44
+ details?: Buffer;
45
+ }
46
+ export type InlineErrorCallback = (error: InlineStreamError) => void;
36
47
  export declare class MacpStream {
37
48
  private readonly call;
38
49
  private readonly queue;
@@ -56,9 +67,11 @@ export declare class MacpStream {
56
67
  * delivery, redeliveries included; see `GrpcTransportAdapter.lastSequence`
57
68
  * for a cursor that tracks the ordinal correctly.
58
69
  * Ordinals are stable across log compaction and runtime restart. Resuming
59
- * below a compacted base returns `FAILED_PRECONDITION` (inspectable via
60
- * `MacpTransportError.code`) rather than silently skipping history (runtime
61
- * ≥ 0.5.0; older runtimes compared inclusively against a raw log index).
70
+ * below a compacted base is refused rather than silently skipping history
71
+ * (runtime ≥ 0.5.0; older runtimes compared inclusively against a raw log
72
+ * index): since runtime 0.9.0 as an inline `INVALID_ENVELOPE` error frame
73
+ * (see `onInlineError`) with the stream left open; on 0.5.0–0.8.x it ended
74
+ * the stream with `FAILED_PRECONDITION` (`MacpTransportError.code`).
62
75
  */
63
76
  sendSubscribe(sessionId: string, afterSequence?: number): Promise<void>;
64
77
  responses(): AsyncGenerator<Envelope, void, void>;
package/dist/client.js CHANGED
@@ -156,13 +156,17 @@ class MacpStream {
156
156
  }
157
157
  else if (chunk?.error) {
158
158
  // Inline application-level error — stream stays open (core.proto).
159
- logging_1.logger.warn('inline stream error', { code: chunk.error.code, message: chunk.error.message });
159
+ logging_1.logger.warn('inline stream error', {
160
+ code: chunk.error.code,
161
+ message: chunk.error.message,
162
+ messageId: chunk.error.messageId,
163
+ });
160
164
  for (const cb of this.inlineErrorCallbacks)
161
165
  cb(chunk.error);
162
166
  }
163
167
  });
164
168
  call.on('error', (error) => {
165
- this.queue.push(new errors_1.MacpTransportError(error.details || error.message, grpcStatusName(error.code)));
169
+ this.queue.push(new errors_1.MacpTransportError(error.details || error.message, grpcStatusName(error.code), (0, errors_1.macpErrorCodeFromMetadata)(grpcMetadataEntries(error.metadata))));
166
170
  });
167
171
  call.on('end', () => {
168
172
  this.queue.push(STREAM_END);
@@ -198,9 +202,11 @@ class MacpStream {
198
202
  * delivery, redeliveries included; see `GrpcTransportAdapter.lastSequence`
199
203
  * for a cursor that tracks the ordinal correctly.
200
204
  * Ordinals are stable across log compaction and runtime restart. Resuming
201
- * below a compacted base returns `FAILED_PRECONDITION` (inspectable via
202
- * `MacpTransportError.code`) rather than silently skipping history (runtime
203
- * ≥ 0.5.0; older runtimes compared inclusively against a raw log index).
205
+ * below a compacted base is refused rather than silently skipping history
206
+ * (runtime ≥ 0.5.0; older runtimes compared inclusively against a raw log
207
+ * index): since runtime 0.9.0 as an inline `INVALID_ENVELOPE` error frame
208
+ * (see `onInlineError`) with the stream left open; on 0.5.0–0.8.x it ended
209
+ * the stream with `FAILED_PRECONDITION` (`MacpTransportError.code`).
204
210
  */
205
211
  sendSubscribe(sessionId, afterSequence = 0) {
206
212
  if (this.closed)
@@ -318,17 +324,23 @@ class MacpClient {
318
324
  const resolved = deadlineMs ?? this.defaultDeadlineMs;
319
325
  return resolved ? new Date(Date.now() + resolved) : undefined;
320
326
  }
321
- unary(method, request, auth, deadlineMs, ackStatuses) {
327
+ unary(method, request, auth, deadlineMs, ackStatuses, registryCodeAsAck = false) {
322
328
  return new Promise((resolve, reject) => {
323
329
  const callback = (error, response) => {
324
330
  if (error) {
325
331
  const statusName = grpcStatusName(error.code);
326
- const mappedCode = statusName ? ackStatuses?.[statusName] : undefined;
332
+ const entries = grpcMetadataEntries(error.metadata);
333
+ // Runtime ≥ 0.9.0 names the registry code in `macp-error-code`
334
+ // metadata; several codes share one gRPC status, so when the call
335
+ // opts in, that code wins over the status-name map (which remains
336
+ // the fallback for older runtimes that send no such key).
337
+ const macpErrorCode = (0, errors_1.macpErrorCodeFromMetadata)(entries);
338
+ const mappedCode = (registryCodeAsAck ? macpErrorCode : undefined) ?? (statusName ? ackStatuses?.[statusName] : undefined);
327
339
  if (mappedCode) {
328
- reject(new errors_1.MacpAckError({ ok: false, error: { code: mappedCode, message: error.details || error.message } }, grpcMetadataEntries(error.metadata)));
340
+ reject(new errors_1.MacpAckError({ ok: false, error: { code: mappedCode, message: error.details || error.message } }, entries));
329
341
  }
330
342
  else {
331
- reject(new errors_1.MacpTransportError(error.details || error.message, statusName));
343
+ reject(new errors_1.MacpTransportError(error.details || error.message, statusName, macpErrorCode));
332
344
  }
333
345
  }
334
346
  else {
@@ -375,7 +387,9 @@ class MacpClient {
375
387
  // gRPC ALREADY_EXISTS/FAILED_PRECONDITION on Send are application-level
376
388
  // rejections the runtime couldn't express as a normal NACK (issue #107);
377
389
  // surface them as MacpAckError, matching macp-sdk-python (client.py:442-452).
378
- { ALREADY_EXISTS: constants_1.SESSION_ALREADY_EXISTS, FAILED_PRECONDITION: constants_1.POLICY_DENIED });
390
+ // That status map is the pre-0.9.0 fallback: a `macp-error-code` key
391
+ // (runtime ≥ 0.9.0) supplies the exact registry code instead.
392
+ { ALREADY_EXISTS: constants_1.SESSION_ALREADY_EXISTS, FAILED_PRECONDITION: constants_1.POLICY_DENIED }, true);
379
393
  const ack = response.ack;
380
394
  // Duplicate acks are success — the message was already accepted
381
395
  if (ack?.duplicate)
@@ -69,3 +69,29 @@ export declare function commitmentHash(payload: CommitmentPayload): string;
69
69
  * `fullmatch` semantics for this pattern.
70
70
  */
71
71
  export declare function isCanonicalCommitmentHash(value: string): boolean;
72
+ /**
73
+ * Result of {@link verifyCommitmentHash} (RFC-MACP-0013 §6.1). `cannot_verify`
74
+ * is a verdict on the payload encoding — never on the claim.
75
+ */
76
+ export type VerifyOutcome = 'match' | 'mismatch' | 'cannot_verify';
77
+ /**
78
+ * Verifies a claimed commitment hash against a `CommitmentPayload`'s Protobuf
79
+ * wire encoding (RFC-MACP-0013 §6.1), returning exactly one of:
80
+ *
81
+ * - `'match'` — the claim equals the canonical hash of the decoded payload;
82
+ * - `'mismatch'` — it does not, including any claim not in canonical form
83
+ * (`SHA256:` prefix, uppercase hex, no prefix, surrounding whitespace):
84
+ * the comparison is exact, never normalized;
85
+ * - `'cannot_verify'` — the encoding is outside what the
86
+ * `macp-commitment-hash/1` label covers: an unknown field in the payload or
87
+ * in `supersedes` (§5), or an unparseable encoding — truncated, a known
88
+ * field with the wrong wire type, invalid UTF-8 in a string, a group, or
89
+ * field number 0 (§6.1 rule 1).
90
+ *
91
+ * Takes the wire bytes, not a decoded object, so unknown fields stay
92
+ * observable. A known field repeated on the wire follows Protobuf merge
93
+ * semantics (last scalar wins, `supersedes` merges), and a zero-length
94
+ * encoding is the all-default payload, which is hashable. Never throws: a
95
+ * non-byte `wire` is `'cannot_verify'`, a non-string claim `'mismatch'`.
96
+ */
97
+ export declare function verifyCommitmentHash(wire: Uint8Array, claimedHash: string): VerifyOutcome;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.canonicalizeCommitmentPayload = canonicalizeCommitmentPayload;
4
4
  exports.commitmentHash = commitmentHash;
5
5
  exports.isCanonicalCommitmentHash = isCanonicalCommitmentHash;
6
+ exports.verifyCommitmentHash = verifyCommitmentHash;
6
7
  /**
7
8
  * RFC-MACP-0013 canonical commitment hashing.
8
9
  *
@@ -230,3 +231,182 @@ function commitmentHash(payload) {
230
231
  function isCanonicalCommitmentHash(value) {
231
232
  return /^sha256:[0-9a-f]{64}$/.test(value);
232
233
  }
234
+ /** Thrown internally by the wire reader; never escapes this module. */
235
+ class CannotVerify extends Error {
236
+ }
237
+ // `ignoreBOM: true` keeps a leading U+FEFF in a string field (the default
238
+ // strips it, which would hash different bytes than the sender's); `fatal`
239
+ // makes invalid UTF-8 throw, which RFC-MACP-0013 §6.1 rule 1 requires to be
240
+ // cannot-verify rather than U+FFFD-substituted.
241
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
242
+ /**
243
+ * Minimal strict Protobuf wire reader for the two message types the
244
+ * `macp-commitment-hash/1` label covers. Hand-written rather than protobufjs
245
+ * because protobufjs silently skips unknown fields, and RFC-MACP-0013 §5
246
+ * requires an unknown field to be cannot-verify, never dropped.
247
+ */
248
+ class WireReader {
249
+ buf;
250
+ pos = 0;
251
+ constructor(buf) {
252
+ this.buf = buf;
253
+ }
254
+ get done() {
255
+ return this.pos >= this.buf.length;
256
+ }
257
+ /** Varint as a Number. Exact up to 2^53; larger values are only ever compared against small bounds. */
258
+ varint() {
259
+ let result = 0;
260
+ let scale = 1;
261
+ for (let i = 0; i < 10; i++) {
262
+ if (this.pos >= this.buf.length)
263
+ throw new CannotVerify('truncated varint');
264
+ const byte = this.buf[this.pos++];
265
+ result += (byte & 0x7f) * scale;
266
+ if ((byte & 0x80) === 0)
267
+ return result;
268
+ scale *= 128;
269
+ }
270
+ throw new CannotVerify('varint longer than 10 bytes');
271
+ }
272
+ /** Reads a varint and reports only whether it is non-zero (proto3 bool). */
273
+ nonZeroVarint() {
274
+ return this.varint() !== 0;
275
+ }
276
+ bytes() {
277
+ const len = this.varint();
278
+ if (len > this.buf.length - this.pos)
279
+ throw new CannotVerify('truncated length-delimited field');
280
+ const out = this.buf.subarray(this.pos, this.pos + len);
281
+ this.pos += len;
282
+ return out;
283
+ }
284
+ string() {
285
+ try {
286
+ return STRICT_UTF8.decode(this.bytes());
287
+ }
288
+ catch (err) {
289
+ if (err instanceof CannotVerify)
290
+ throw err;
291
+ throw new CannotVerify('invalid UTF-8');
292
+ }
293
+ }
294
+ /** Field number and wire type of the next tag; field 0, groups, and wire types 6/7 are unparseable. */
295
+ tag() {
296
+ const key = this.varint();
297
+ if (key > 0xffffffff)
298
+ throw new CannotVerify('tag out of range');
299
+ const field = Math.floor(key / 8);
300
+ const wireType = key % 8;
301
+ if (field === 0)
302
+ throw new CannotVerify('field number 0');
303
+ if (wireType === 3 || wireType === 4)
304
+ throw new CannotVerify('group');
305
+ if (wireType > 5)
306
+ throw new CannotVerify(`invalid wire type ${wireType}`);
307
+ return { field, wireType };
308
+ }
309
+ }
310
+ const WIRE_VARINT = 0;
311
+ const WIRE_LEN = 2;
312
+ // Field numbers of the frozen set (RFC-MACP-0013 §5), in proto order.
313
+ const PAYLOAD_STRING_FIELDS = {
314
+ 1: 'commitmentId',
315
+ 2: 'action',
316
+ 3: 'authorityScope',
317
+ 4: 'reason',
318
+ 5: 'modeVersion',
319
+ 6: 'policyVersion',
320
+ 7: 'configurationVersion',
321
+ };
322
+ const FIELD_OUTCOME_POSITIVE = 8;
323
+ const FIELD_SUPERSEDES = 9;
324
+ const REF_STRING_FIELDS = { 1: 'sessionId', 2: 'commitmentHash' };
325
+ function expectWireType(actual, expected) {
326
+ if (actual !== expected)
327
+ throw new CannotVerify('known field with the wrong wire type');
328
+ }
329
+ /** Merges one `CommitmentRef` occurrence into `ref` (Protobuf message merge: present sub-fields overwrite). */
330
+ function mergeCommitmentRef(wire, ref) {
331
+ const r = new WireReader(wire);
332
+ while (!r.done) {
333
+ const { field, wireType } = r.tag();
334
+ const name = REF_STRING_FIELDS[field];
335
+ // `supersedes` carries exactly two fields (§5): anything else is outside the label.
336
+ if (name === undefined)
337
+ throw new CannotVerify(`unknown CommitmentRef field ${field}`);
338
+ expectWireType(wireType, WIRE_LEN);
339
+ ref[name] = r.string();
340
+ }
341
+ }
342
+ /** Strictly decodes a `CommitmentPayload` wire encoding; throws `CannotVerify` on anything §5/§6.1 excludes. */
343
+ function decodeCommitmentPayloadStrict(wire) {
344
+ // Proto3 defaults: a zero-length encoding is the all-default payload (§6.1 rule 3).
345
+ const payload = {
346
+ commitmentId: '',
347
+ action: '',
348
+ authorityScope: '',
349
+ reason: '',
350
+ modeVersion: '',
351
+ policyVersion: '',
352
+ configurationVersion: '',
353
+ outcomePositive: false,
354
+ };
355
+ const r = new WireReader(wire);
356
+ while (!r.done) {
357
+ const { field, wireType } = r.tag();
358
+ const name = PAYLOAD_STRING_FIELDS[field];
359
+ if (name !== undefined) {
360
+ expectWireType(wireType, WIRE_LEN);
361
+ payload[name] = r.string(); // last value wins (§6.1 rule 2)
362
+ }
363
+ else if (field === FIELD_OUTCOME_POSITIVE) {
364
+ expectWireType(wireType, WIRE_VARINT);
365
+ payload.outcomePositive = r.nonZeroVarint();
366
+ }
367
+ else if (field === FIELD_SUPERSEDES) {
368
+ expectWireType(wireType, WIRE_LEN);
369
+ // Present even when empty; repeated occurrences merge (§6.1 rule 2).
370
+ payload.supersedes ??= { sessionId: '', commitmentHash: '' };
371
+ mergeCommitmentRef(r.bytes(), payload.supersedes);
372
+ }
373
+ else {
374
+ throw new CannotVerify(`field ${field} is outside the macp-commitment-hash/1 frozen set`);
375
+ }
376
+ }
377
+ return payload;
378
+ }
379
+ /**
380
+ * Verifies a claimed commitment hash against a `CommitmentPayload`'s Protobuf
381
+ * wire encoding (RFC-MACP-0013 §6.1), returning exactly one of:
382
+ *
383
+ * - `'match'` — the claim equals the canonical hash of the decoded payload;
384
+ * - `'mismatch'` — it does not, including any claim not in canonical form
385
+ * (`SHA256:` prefix, uppercase hex, no prefix, surrounding whitespace):
386
+ * the comparison is exact, never normalized;
387
+ * - `'cannot_verify'` — the encoding is outside what the
388
+ * `macp-commitment-hash/1` label covers: an unknown field in the payload or
389
+ * in `supersedes` (§5), or an unparseable encoding — truncated, a known
390
+ * field with the wrong wire type, invalid UTF-8 in a string, a group, or
391
+ * field number 0 (§6.1 rule 1).
392
+ *
393
+ * Takes the wire bytes, not a decoded object, so unknown fields stay
394
+ * observable. A known field repeated on the wire follows Protobuf merge
395
+ * semantics (last scalar wins, `supersedes` merges), and a zero-length
396
+ * encoding is the all-default payload, which is hashable. Never throws: a
397
+ * non-byte `wire` is `'cannot_verify'`, a non-string claim `'mismatch'`.
398
+ */
399
+ function verifyCommitmentHash(wire, claimedHash) {
400
+ if (!(wire instanceof Uint8Array))
401
+ return 'cannot_verify';
402
+ let payload;
403
+ try {
404
+ payload = decodeCommitmentPayloadStrict(wire);
405
+ }
406
+ catch (err) {
407
+ if (err instanceof CannotVerify)
408
+ return 'cannot_verify';
409
+ throw err; // a bug in the reader, not a property of the input — surface it
410
+ }
411
+ return typeof claimedHash === 'string' && claimedHash === commitmentHash(payload) ? 'match' : 'mismatch';
412
+ }
@@ -29,3 +29,32 @@ export declare const POLICY_DENIED = "POLICY_DENIED";
29
29
  export declare const INVALID_SESSION_ID = "INVALID_SESSION_ID";
30
30
  export declare const UNKNOWN_POLICY_VERSION = "UNKNOWN_POLICY_VERSION";
31
31
  export declare const INVALID_POLICY_DEFINITION = "INVALID_POLICY_DEFINITION";
32
+ /** Register/UnregisterPolicy against a file-loaded (read-only) policy registry — FAILED_PRECONDITION. */
33
+ export declare const REGISTRY_READ_ONLY = "REGISTRY_READ_ONLY";
34
+ /** Malformed control-plane request (bad page_size/page_token, missing descriptor, …) — INVALID_ARGUMENT. */
35
+ export declare const INVALID_REQUEST = "INVALID_REQUEST";
36
+ /** Stream/watch subscriber fell behind — RESOURCE_EXHAUSTED; resubscribe with `afterSequence`. */
37
+ export declare const STREAM_LAGGED = "STREAM_LAGGED";
38
+ /** GetManifest for an agent this runtime does not host — NOT_FOUND. */
39
+ export declare const AGENT_NOT_FOUND = "AGENT_NOT_FOUND";
40
+ export declare const DENY_VETO = "veto";
41
+ export declare const DENY_VETO_HOLD = "veto_hold";
42
+ export declare const DENY_QUORUM_NOT_MET = "quorum_not_met";
43
+ export declare const DENY_THRESHOLD_NOT_MET = "threshold_not_met";
44
+ export declare const DENY_CONFIDENCE_BELOW_MINIMUM = "confidence_below_minimum";
45
+ export declare const DENY_EVALUATION_REQUIRED = "evaluation_required";
46
+ export declare const DENY_NO_VOTES = "no_votes";
47
+ export declare const DENY_DECLINE_OVER_APPROVAL = "decline_over_approval";
48
+ export declare const DENY_DECLINE_NOT_BACKED = "decline_not_backed";
49
+ export declare const DENY_CRITERION_NOT_MET = "criterion_not_met";
50
+ export declare const DENY_OUTPUT_REQUIRED = "output_required";
51
+ export declare const DENY_REASSIGNMENT_NOT_ALLOWED = "reassignment_not_allowed";
52
+ export declare const DENY_UNSUPPORTED_SCHEMA_VERSION = "unsupported_schema_version";
53
+ export declare const DENY_INVALID_RULES = "invalid_rules";
54
+ export declare const DENY_UNSPECIFIED = "unspecified";
55
+ /**
56
+ * The denial codes the reference evaluator emits. A custom evaluator may send
57
+ * others — `PolicyDenial.code` passes them through unchanged, so treat a code
58
+ * not in this set as {@link DENY_UNSPECIFIED}.
59
+ */
60
+ export declare const KNOWN_DENY_CODES: ReadonlySet<string>;
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.INVALID_POLICY_DEFINITION = exports.UNKNOWN_POLICY_VERSION = exports.INVALID_SESSION_ID = exports.POLICY_DENIED = exports.INTERNAL_ERROR = exports.RATE_LIMITED = exports.PAYLOAD_TOO_LARGE = exports.DUPLICATE_MESSAGE = exports.UNAUTHENTICATED = exports.FORBIDDEN = exports.MODE_NOT_SUPPORTED = exports.SESSION_NOT_OPEN = exports.SESSION_NOT_FOUND = exports.SESSION_ALREADY_EXISTS = exports.INVALID_ENVELOPE = exports.UNSUPPORTED_PROTOCOL_VERSION = exports.STANDARD_MODES = exports.MODE_MULTI_ROUND = exports.MODE_QUORUM = exports.MODE_HANDOFF = exports.MODE_TASK = exports.MODE_PROPOSAL = exports.MODE_DECISION = exports.DEFAULT_POLICY_VERSION = exports.DEFAULT_CONFIGURATION_VERSION = exports.DEFAULT_MODE_VERSION = exports.MACP_VERSION = void 0;
3
+ exports.KNOWN_DENY_CODES = exports.DENY_UNSPECIFIED = exports.DENY_INVALID_RULES = exports.DENY_UNSUPPORTED_SCHEMA_VERSION = exports.DENY_REASSIGNMENT_NOT_ALLOWED = exports.DENY_OUTPUT_REQUIRED = exports.DENY_CRITERION_NOT_MET = exports.DENY_DECLINE_NOT_BACKED = exports.DENY_DECLINE_OVER_APPROVAL = exports.DENY_NO_VOTES = exports.DENY_EVALUATION_REQUIRED = exports.DENY_CONFIDENCE_BELOW_MINIMUM = exports.DENY_THRESHOLD_NOT_MET = exports.DENY_QUORUM_NOT_MET = exports.DENY_VETO_HOLD = exports.DENY_VETO = exports.AGENT_NOT_FOUND = exports.STREAM_LAGGED = exports.INVALID_REQUEST = exports.REGISTRY_READ_ONLY = exports.INVALID_POLICY_DEFINITION = exports.UNKNOWN_POLICY_VERSION = exports.INVALID_SESSION_ID = exports.POLICY_DENIED = exports.INTERNAL_ERROR = exports.RATE_LIMITED = exports.PAYLOAD_TOO_LARGE = exports.DUPLICATE_MESSAGE = exports.UNAUTHENTICATED = exports.FORBIDDEN = exports.MODE_NOT_SUPPORTED = exports.SESSION_NOT_OPEN = exports.SESSION_NOT_FOUND = exports.SESSION_ALREADY_EXISTS = exports.INVALID_ENVELOPE = exports.UNSUPPORTED_PROTOCOL_VERSION = exports.STANDARD_MODES = exports.MODE_MULTI_ROUND = exports.MODE_QUORUM = exports.MODE_HANDOFF = exports.MODE_TASK = exports.MODE_PROPOSAL = exports.MODE_DECISION = exports.DEFAULT_POLICY_VERSION = exports.DEFAULT_CONFIGURATION_VERSION = exports.DEFAULT_MODE_VERSION = exports.MACP_VERSION = void 0;
4
4
  exports.MACP_VERSION = '1.0';
5
5
  exports.DEFAULT_MODE_VERSION = '1.0.0';
6
6
  exports.DEFAULT_CONFIGURATION_VERSION = 'config.default';
@@ -34,3 +34,52 @@ exports.POLICY_DENIED = 'POLICY_DENIED';
34
34
  exports.INVALID_SESSION_ID = 'INVALID_SESSION_ID';
35
35
  exports.UNKNOWN_POLICY_VERSION = 'UNKNOWN_POLICY_VERSION';
36
36
  exports.INVALID_POLICY_DEFINITION = 'INVALID_POLICY_DEFINITION';
37
+ // Control-plane codes added in runtime 0.9.0 (carried in `macp-error-code`
38
+ // metadata; see `MacpTransportError.macpErrorCode` / `MacpAckError.macpErrorCode`).
39
+ /** Register/UnregisterPolicy against a file-loaded (read-only) policy registry — FAILED_PRECONDITION. */
40
+ exports.REGISTRY_READ_ONLY = 'REGISTRY_READ_ONLY';
41
+ /** Malformed control-plane request (bad page_size/page_token, missing descriptor, …) — INVALID_ARGUMENT. */
42
+ exports.INVALID_REQUEST = 'INVALID_REQUEST';
43
+ /** Stream/watch subscriber fell behind — RESOURCE_EXHAUSTED; resubscribe with `afterSequence`. */
44
+ exports.STREAM_LAGGED = 'STREAM_LAGGED';
45
+ /** GetManifest for an agent this runtime does not host — NOT_FOUND. */
46
+ exports.AGENT_NOT_FOUND = 'AGENT_NOT_FOUND';
47
+ // Structured POLICY_DENIED denial codes (`PolicyDenial.code`, runtime ≥ 0.9.0;
48
+ // RFC-MACP-0012 §6.2). Open set — names match python-sdk's exports.
49
+ exports.DENY_VETO = 'veto';
50
+ exports.DENY_VETO_HOLD = 'veto_hold';
51
+ exports.DENY_QUORUM_NOT_MET = 'quorum_not_met';
52
+ exports.DENY_THRESHOLD_NOT_MET = 'threshold_not_met';
53
+ exports.DENY_CONFIDENCE_BELOW_MINIMUM = 'confidence_below_minimum';
54
+ exports.DENY_EVALUATION_REQUIRED = 'evaluation_required';
55
+ exports.DENY_NO_VOTES = 'no_votes';
56
+ exports.DENY_DECLINE_OVER_APPROVAL = 'decline_over_approval';
57
+ exports.DENY_DECLINE_NOT_BACKED = 'decline_not_backed';
58
+ exports.DENY_CRITERION_NOT_MET = 'criterion_not_met';
59
+ exports.DENY_OUTPUT_REQUIRED = 'output_required';
60
+ exports.DENY_REASSIGNMENT_NOT_ALLOWED = 'reassignment_not_allowed';
61
+ exports.DENY_UNSUPPORTED_SCHEMA_VERSION = 'unsupported_schema_version';
62
+ exports.DENY_INVALID_RULES = 'invalid_rules';
63
+ exports.DENY_UNSPECIFIED = 'unspecified';
64
+ /**
65
+ * The denial codes the reference evaluator emits. A custom evaluator may send
66
+ * others — `PolicyDenial.code` passes them through unchanged, so treat a code
67
+ * not in this set as {@link DENY_UNSPECIFIED}.
68
+ */
69
+ exports.KNOWN_DENY_CODES = new Set([
70
+ exports.DENY_VETO,
71
+ exports.DENY_VETO_HOLD,
72
+ exports.DENY_QUORUM_NOT_MET,
73
+ exports.DENY_THRESHOLD_NOT_MET,
74
+ exports.DENY_CONFIDENCE_BELOW_MINIMUM,
75
+ exports.DENY_EVALUATION_REQUIRED,
76
+ exports.DENY_NO_VOTES,
77
+ exports.DENY_DECLINE_OVER_APPROVAL,
78
+ exports.DENY_DECLINE_NOT_BACKED,
79
+ exports.DENY_CRITERION_NOT_MET,
80
+ exports.DENY_OUTPUT_REQUIRED,
81
+ exports.DENY_REASSIGNMENT_NOT_ALLOWED,
82
+ exports.DENY_UNSUPPORTED_SCHEMA_VERSION,
83
+ exports.DENY_INVALID_RULES,
84
+ exports.DENY_UNSPECIFIED,
85
+ ]);
package/dist/decision.js CHANGED
@@ -95,7 +95,10 @@ class DecisionSession {
95
95
  }
96
96
  async raiseObjection(input) {
97
97
  (0, validation_1.validateRequiredField)('proposalId', input.proposalId);
98
- const severity = input.severity ? (0, validation_1.validateSeverity)(input.severity) : input.severity;
98
+ // Always send an explicit lowercase severity: runtime ≥ 0.9.0 sessions
99
+ // (semantics_rev 4) reject an empty one instead of defaulting to
100
+ // "medium", so the SDK applies that default itself (python-sdk parity).
101
+ const severity = (0, validation_1.validateSeverity)(input.severity ?? 'medium');
99
102
  const envelope = (0, envelope_1.buildEnvelope)({
100
103
  mode: constants_1.MODE_DECISION,
101
104
  messageType: 'Objection',
package/dist/errors.d.ts CHANGED
@@ -8,12 +8,62 @@ export declare class MacpTransportError extends MacpSdkError {
8
8
  * `UNAUTHENTICATED`) when the underlying transport failure carried one.
9
9
  * Lets consumers distinguish, for example, watch-stream consumer lag
10
10
  * (`RESOURCE_EXHAUSTED` → reconnect) from an auth failure (`UNAUTHENTICATED`
11
- * → do not reconnect), or a passive-subscribe resume below a compacted base
12
- * (`FAILED_PRECONDITION`). Absent for locally-raised transport errors.
11
+ * → do not reconnect). Absent for locally-raised transport errors. For a
12
+ * runtime-raised MACP error prefer {@link macpErrorCode}.
13
13
  */
14
14
  readonly code?: string;
15
- constructor(message: string, code?: string);
15
+ /**
16
+ * MACP error-codes registry code (e.g. `SESSION_NOT_FOUND`, `POLICY_DENIED`)
17
+ * from the status's `macp-error-code` trailing metadata (runtime ≥ 0.9.0).
18
+ * Branch on this rather than {@link code}: since 0.9.0 the gRPC status
19
+ * follows the registry's gRPC column, and several registry codes share one
20
+ * status (e.g. `INVALID_ENVELOPE` and `INVALID_SESSION_ID` are both
21
+ * `INVALID_ARGUMENT`). Absent for older runtimes, for statuses the runtime
22
+ * raised without a registry code, and for locally-raised errors.
23
+ */
24
+ readonly macpErrorCode?: string;
25
+ constructor(message: string, code?: string, macpErrorCode?: string);
26
+ }
27
+ /**
28
+ * One structured `POLICY_DENIED` reason (RFC-MACP-0012 §6.2, runtime ≥ 0.9.0).
29
+ *
30
+ * `code` is an open set: the reference evaluator emits `veto`, `veto_hold`,
31
+ * `quorum_not_met`, `threshold_not_met`, `confidence_below_minimum`,
32
+ * `evaluation_required`, `no_votes`, `decline_over_approval`,
33
+ * `decline_not_backed`, `criterion_not_met`, `output_required`,
34
+ * `reassignment_not_allowed`, `unsupported_schema_version`, `invalid_rules`
35
+ * and `unspecified` (exported as the `DENY_*` constants and
36
+ * {@link KNOWN_DENY_CODES}), but a custom evaluator may mint its own. `code`
37
+ * is the wire value passed through unchanged — treat one not in
38
+ * `KNOWN_DENY_CODES` as `unspecified`. `participants` lists the participant
39
+ * ids the reason concerns (e.g. a veto's objectors); since 0.9.0 they are no
40
+ * longer embedded in `message`.
41
+ */
42
+ export interface PolicyDenial {
43
+ code: string;
44
+ message: string;
45
+ participants: string[];
16
46
  }
47
+ /** Parsed MACP error `details` JSON: legacy `reasons` strings plus structured `denials`. */
48
+ export interface MacpErrorDetails {
49
+ reasons: string[];
50
+ denials: PolicyDenial[];
51
+ }
52
+ /**
53
+ * Parse the JSON `details` the runtime attaches to a `POLICY_DENIED` error —
54
+ * found on `Ack.error.details`, an inline stream `MACPError.details`, or the
55
+ * `macp-error-details-bin` trailing metadata. Never throws: missing,
56
+ * non-JSON, or malformed input yields empty arrays; a `denials` entry that is
57
+ * not an object is dropped, and one without a string `message` is kept with
58
+ * `message: ''`. Pre-0.9.0 runtimes send `reasons` only,
59
+ * so `denials` is `[]` for them.
60
+ */
61
+ export declare function parseErrorDetails(details: Buffer | Uint8Array | string | null | undefined): MacpErrorDetails;
62
+ /** Value of the `macp-error-code` trailing-metadata key, if present (runtime ≥ 0.9.0). */
63
+ export declare function macpErrorCodeFromMetadata(grpcMetadata?: Array<{
64
+ key: string;
65
+ value: string | Buffer;
66
+ }>): string | undefined;
17
67
  /**
18
68
  * Structured NACK record. Mirrors python-sdk's `AckFailure` dataclass so
19
69
  * cross-SDK code can share the same shape when logging or persisting a
@@ -25,6 +75,11 @@ export interface AckFailure {
25
75
  sessionId: string;
26
76
  messageId: string;
27
77
  reasons: string[];
78
+ /**
79
+ * Structured policy-denial reasons (runtime ≥ 0.9.0). Empty for non-policy
80
+ * NACKs and for older runtimes, which send `reasons` strings only.
81
+ */
82
+ denials: PolicyDenial[];
28
83
  }
29
84
  export declare class MacpAckError extends MacpSdkError {
30
85
  readonly ack: Ack;
@@ -35,13 +90,19 @@ export declare class MacpAckError extends MacpSdkError {
35
90
  }>;
36
91
  /** Structured NACK record — parity with python-sdk `MacpAckError.failure`. */
37
92
  readonly failure: AckFailure;
93
+ /**
94
+ * The registry code from the `macp-error-code` metadata (runtime ≥ 0.9.0)
95
+ * when this NACK was synthesized from a gRPC status. Usually equal to
96
+ * `failure.code`; differs on the registry-mutation RPCs, which keep
97
+ * `failure.code: 'FAILED_PRECONDITION'` for compatibility while this names
98
+ * the precise cause (e.g. `REGISTRY_READ_ONLY`).
99
+ */
100
+ readonly macpErrorCode?: string;
38
101
  constructor(ack: Ack, grpcMetadata?: Array<{
39
102
  key: string;
40
103
  value: string | Buffer;
41
104
  }>);
42
- private static _extractReasons;
43
- private static _parseAckReasons;
44
- private static _parseGrpcMetadataReasons;
105
+ private static _extractDetails;
45
106
  }
46
107
  export declare class MacpSessionError extends MacpSdkError {
47
108
  constructor(message: string);
package/dist/errors.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MacpIdentityMismatchError = exports.MacpRetryError = exports.MacpTimeoutError = exports.MacpSessionError = exports.MacpAckError = exports.MacpTransportError = exports.MacpSdkError = void 0;
4
+ exports.parseErrorDetails = parseErrorDetails;
5
+ exports.macpErrorCodeFromMetadata = macpErrorCodeFromMetadata;
4
6
  class MacpSdkError extends Error {
5
7
  constructor(message) {
6
8
  super(message);
@@ -14,80 +16,121 @@ class MacpTransportError extends MacpSdkError {
14
16
  * `UNAUTHENTICATED`) when the underlying transport failure carried one.
15
17
  * Lets consumers distinguish, for example, watch-stream consumer lag
16
18
  * (`RESOURCE_EXHAUSTED` → reconnect) from an auth failure (`UNAUTHENTICATED`
17
- * → do not reconnect), or a passive-subscribe resume below a compacted base
18
- * (`FAILED_PRECONDITION`). Absent for locally-raised transport errors.
19
+ * → do not reconnect). Absent for locally-raised transport errors. For a
20
+ * runtime-raised MACP error prefer {@link macpErrorCode}.
19
21
  */
20
22
  code;
21
- constructor(message, code) {
23
+ /**
24
+ * MACP error-codes registry code (e.g. `SESSION_NOT_FOUND`, `POLICY_DENIED`)
25
+ * from the status's `macp-error-code` trailing metadata (runtime ≥ 0.9.0).
26
+ * Branch on this rather than {@link code}: since 0.9.0 the gRPC status
27
+ * follows the registry's gRPC column, and several registry codes share one
28
+ * status (e.g. `INVALID_ENVELOPE` and `INVALID_SESSION_ID` are both
29
+ * `INVALID_ARGUMENT`). Absent for older runtimes, for statuses the runtime
30
+ * raised without a registry code, and for locally-raised errors.
31
+ */
32
+ macpErrorCode;
33
+ constructor(message, code, macpErrorCode) {
22
34
  super(message);
23
35
  this.name = 'MacpTransportError';
24
36
  this.code = code;
37
+ if (macpErrorCode !== undefined)
38
+ this.macpErrorCode = macpErrorCode;
25
39
  }
26
40
  }
27
41
  exports.MacpTransportError = MacpTransportError;
42
+ /**
43
+ * Parse the JSON `details` the runtime attaches to a `POLICY_DENIED` error —
44
+ * found on `Ack.error.details`, an inline stream `MACPError.details`, or the
45
+ * `macp-error-details-bin` trailing metadata. Never throws: missing,
46
+ * non-JSON, or malformed input yields empty arrays; a `denials` entry that is
47
+ * not an object is dropped, and one without a string `message` is kept with
48
+ * `message: ''`. Pre-0.9.0 runtimes send `reasons` only,
49
+ * so `denials` is `[]` for them.
50
+ */
51
+ function parseErrorDetails(details) {
52
+ const empty = { reasons: [], denials: [] };
53
+ if (details === null || details === undefined)
54
+ return empty;
55
+ let parsed;
56
+ try {
57
+ const raw = typeof details === 'string' ? details : Buffer.from(details).toString('utf-8');
58
+ if (raw.length === 0)
59
+ return empty;
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ return empty;
64
+ }
65
+ if (typeof parsed !== 'object' || parsed === null)
66
+ return empty;
67
+ const obj = parsed;
68
+ const reasons = Array.isArray(obj.reasons) ? obj.reasons.filter((r) => typeof r === 'string') : [];
69
+ const denials = [];
70
+ if (Array.isArray(obj.denials)) {
71
+ for (const d of obj.denials) {
72
+ if (typeof d !== 'object' || d === null)
73
+ continue;
74
+ const { code, message, participants } = d;
75
+ denials.push({
76
+ code: typeof code === 'string' && code.length > 0 ? code : 'unspecified',
77
+ // A denial without a string message is kept with '' (python-sdk
78
+ // parity, issue #168) — its code and participants still matter.
79
+ message: typeof message === 'string' ? message : '',
80
+ participants: Array.isArray(participants) ? participants.filter((p) => typeof p === 'string') : [],
81
+ });
82
+ }
83
+ }
84
+ return { reasons, denials };
85
+ }
86
+ /** Value of the `macp-error-code` trailing-metadata key, if present (runtime ≥ 0.9.0). */
87
+ function macpErrorCodeFromMetadata(grpcMetadata) {
88
+ const entry = grpcMetadata?.find((item) => item.key === 'macp-error-code');
89
+ if (!entry)
90
+ return undefined;
91
+ const value = typeof entry.value === 'string' ? entry.value : entry.value.toString('utf-8');
92
+ return value.length > 0 ? value : undefined;
93
+ }
28
94
  class MacpAckError extends MacpSdkError {
29
95
  ack;
30
96
  /** Optional gRPC trailing metadata for extracting additional error details. */
31
97
  grpcMetadata;
32
98
  /** Structured NACK record — parity with python-sdk `MacpAckError.failure`. */
33
99
  failure;
100
+ /**
101
+ * The registry code from the `macp-error-code` metadata (runtime ≥ 0.9.0)
102
+ * when this NACK was synthesized from a gRPC status. Usually equal to
103
+ * `failure.code`; differs on the registry-mutation RPCs, which keep
104
+ * `failure.code: 'FAILED_PRECONDITION'` for compatibility while this names
105
+ * the precise cause (e.g. `REGISTRY_READ_ONLY`).
106
+ */
107
+ macpErrorCode;
34
108
  constructor(ack, grpcMetadata) {
35
109
  super(`${ack.error?.code ?? 'UNKNOWN'}: ${ack.error?.message ?? 'runtime returned nack'}`);
36
110
  this.name = 'MacpAckError';
37
111
  this.ack = ack;
38
112
  this.grpcMetadata = grpcMetadata;
39
- const reasons = MacpAckError._extractReasons(ack, grpcMetadata);
113
+ const { reasons, denials } = MacpAckError._extractDetails(ack, grpcMetadata);
40
114
  this.failure = {
41
115
  code: ack.error?.code ?? 'UNKNOWN',
42
116
  message: ack.error?.message ?? 'runtime returned nack',
43
117
  sessionId: ack.sessionId ?? ack.error?.sessionId ?? '',
44
118
  messageId: ack.messageId ?? ack.error?.messageId ?? '',
45
119
  reasons,
120
+ denials,
46
121
  };
122
+ const macpErrorCode = macpErrorCodeFromMetadata(grpcMetadata);
123
+ if (macpErrorCode !== undefined)
124
+ this.macpErrorCode = macpErrorCode;
47
125
  }
48
- static _extractReasons(ack, grpcMetadata) {
49
- // First try: parse from ack.error.details (matches Python _parse_ack_reasons)
50
- const fromDetails = MacpAckError._parseAckReasons(ack);
51
- if (fromDetails.length > 0)
52
- return fromDetails;
53
- // Second try: parse from gRPC trailing metadata (matches Python _parse_grpc_metadata_reasons)
54
- return MacpAckError._parseGrpcMetadataReasons(grpcMetadata);
55
- }
56
- static _parseAckReasons(ack) {
57
- if (!ack.error?.details)
58
- return [];
59
- try {
60
- const details = ack.error.details;
61
- const raw = Buffer.isBuffer(details) ? details.toString('utf-8') : String(details);
62
- const parsed = JSON.parse(raw);
63
- const reasons = parsed.reasons;
64
- return Array.isArray(reasons) ? reasons : [];
65
- }
66
- catch {
67
- return [];
68
- }
69
- }
70
- static _parseGrpcMetadataReasons(grpcMetadata) {
71
- if (!grpcMetadata)
72
- return [];
73
- try {
74
- for (const item of grpcMetadata) {
75
- if (item.key === 'macp-error-details-bin') {
76
- const data = Buffer.isBuffer(item.value)
77
- ? item.value.toString('utf-8')
78
- : typeof item.value === 'string'
79
- ? item.value
80
- : Buffer.from(item.value).toString('utf-8');
81
- const parsed = JSON.parse(data);
82
- const reasons = parsed.reasons;
83
- return Array.isArray(reasons) ? reasons : [];
84
- }
85
- }
86
- }
87
- catch {
88
- // Intentionally swallowed — matches Python's bare except
89
- }
90
- return [];
126
+ static _extractDetails(ack, grpcMetadata) {
127
+ // First try: ack.error.details (matches Python _parse_ack_reasons)
128
+ const fromAck = parseErrorDetails(ack.error?.details);
129
+ if (fromAck.reasons.length > 0 || fromAck.denials.length > 0)
130
+ return fromAck;
131
+ // Second try: gRPC trailing metadata (matches Python _parse_grpc_metadata_reasons)
132
+ const entry = grpcMetadata?.find((item) => item.key === 'macp-error-details-bin');
133
+ return parseErrorDetails(entry?.value);
91
134
  }
92
135
  }
93
136
  exports.MacpAckError = MacpAckError;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Auth, type AuthConfig, type BearerAuthOptions } from './auth';
2
2
  export * from './base-session';
3
3
  export * from './client';
4
- export { commitmentHash, isCanonicalCommitmentHash } from './commitment-hash';
4
+ export { commitmentHash, isCanonicalCommitmentHash, verifyCommitmentHash, type VerifyOutcome } from './commitment-hash';
5
5
  export * from './constants';
6
6
  export * from './decision';
7
7
  export * from './envelope';
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.agent = exports.VERSION = exports.logger = exports.configureLogging = exports.isCanonicalCommitmentHash = exports.commitmentHash = exports.Auth = void 0;
39
+ exports.agent = exports.VERSION = exports.logger = exports.configureLogging = exports.verifyCommitmentHash = exports.isCanonicalCommitmentHash = exports.commitmentHash = exports.Auth = void 0;
40
40
  // Auth: export only the public constructors and types. The helpers
41
41
  // `validateAuth`, `authSender`, `assertSenderMatchesIdentity`, and
42
42
  // `metadataFromAuth` are SDK-internal — importable from the `./auth`
@@ -46,7 +46,8 @@ var auth_1 = require("./auth");
46
46
  Object.defineProperty(exports, "Auth", { enumerable: true, get: function () { return auth_1.Auth; } });
47
47
  __exportStar(require("./base-session"), exports);
48
48
  __exportStar(require("./client"), exports);
49
- // Commitment hash: export `commitmentHash` and `isCanonicalCommitmentHash`.
49
+ // Commitment hash: export `commitmentHash`, `isCanonicalCommitmentHash`, and
50
+ // the RFC-MACP-0013 §6.1 verifier `verifyCommitmentHash`.
50
51
  // `canonicalizeCommitmentPayload` is the projection/JCS half of the
51
52
  // algorithm — importable from the `./commitment-hash` submodule (as the
52
53
  // vector-runner tests do) but kept off the top-level surface to match
@@ -55,6 +56,7 @@ __exportStar(require("./client"), exports);
55
56
  var commitment_hash_1 = require("./commitment-hash");
56
57
  Object.defineProperty(exports, "commitmentHash", { enumerable: true, get: function () { return commitment_hash_1.commitmentHash; } });
57
58
  Object.defineProperty(exports, "isCanonicalCommitmentHash", { enumerable: true, get: function () { return commitment_hash_1.isCanonicalCommitmentHash; } });
59
+ Object.defineProperty(exports, "verifyCommitmentHash", { enumerable: true, get: function () { return commitment_hash_1.verifyCommitmentHash; } });
58
60
  __exportStar(require("./constants"), exports);
59
61
  __exportStar(require("./decision"), exports);
60
62
  __exportStar(require("./envelope"), exports);
package/dist/types.d.ts CHANGED
@@ -173,6 +173,7 @@ export interface DecisionEvaluationPayload {
173
173
  export interface DecisionObjectionPayload {
174
174
  proposalId: string;
175
175
  reason: string;
176
+ /** `critical` | `high` | `medium` | `low` (case-insensitive; sent lowercase). Defaults to `medium`. */
176
177
  severity?: string;
177
178
  }
178
179
  export interface DecisionVotePayload {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Validate that `sid` is a lowercase UUID v4/v7 or base64url (22+ chars).
2
+ * Validate that `sid` is a lowercase UUID v4/v7 or base64url (22–128 chars).
3
3
  *
4
4
  * Mirrors the runtime's **no-fall-through** rule (issue #108.1, and
5
5
  * `macp-sdk-python`'s `validate_session_id`, `validation.py:33-58`, which
@@ -20,9 +20,12 @@ const errors_1 = require("./errors");
20
20
  const UUID_SHAPE_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
21
21
  // Strict lowercase UUID v4/v7 (version nibble 4 or 7, RFC 9562 variant 8/9/a/b).
22
22
  const UUID_V4V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
23
- const BASE64URL_RE = /^[A-Za-z0-9_-]{22,}$/;
23
+ // 22–128 chars: the 128 cap is RFC-MACP-0004 §11.2's portability floor, which
24
+ // runtime ≥ 0.9.0 enforces at SessionStart (INVALID_SESSION_ID). The charset is
25
+ // ASCII, so 128 chars == 128 bytes.
26
+ const BASE64URL_RE = /^[A-Za-z0-9_-]{22,128}$/;
24
27
  /**
25
- * Validate that `sid` is a lowercase UUID v4/v7 or base64url (22+ chars).
28
+ * Validate that `sid` is a lowercase UUID v4/v7 or base64url (22–128 chars).
26
29
  *
27
30
  * Mirrors the runtime's **no-fall-through** rule (issue #108.1, and
28
31
  * `macp-sdk-python`'s `validate_session_id`, `validation.py:33-58`, which
@@ -44,7 +47,7 @@ function validateSessionId(sid) {
44
47
  }
45
48
  if (BASE64URL_RE.test(sid))
46
49
  return;
47
- throw new errors_1.MacpSessionError(`session_id must be a lowercase UUID v4/v7 or base64url (22+ chars), got: ${sid}`);
50
+ throw new errors_1.MacpSessionError(`session_id must be a lowercase UUID v4/v7 or base64url (22–128 chars), got: ${sid}`);
48
51
  }
49
52
  const VALID_VOTES = new Set(['APPROVE', 'REJECT', 'ABSTAIN']);
50
53
  function validateVote(value) {
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.1";
7
+ export declare const VERSION = "0.15.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.14.1'; // x-release-please-version
10
+ exports.VERSION = '0.15.0'; // x-release-please-version
package/dist/watchers.js CHANGED
@@ -55,7 +55,9 @@ function serverStreamToAsyncGenerator(stream) {
55
55
  // WatchSignals with UNAUTHENTICATED (fix auth, do NOT reconnect).
56
56
  const code = (0, client_1.grpcStatusName)(error.code);
57
57
  const serviceErr = error;
58
- const wrapped = new errors_1.MacpTransportError(serviceErr.details || error.message, code);
58
+ const wrapped = new errors_1.MacpTransportError(serviceErr.details || error.message, code, (0, errors_1.macpErrorCodeFromMetadata)(serviceErr.metadata
59
+ ? serviceErr.metadata.get('macp-error-code').map((value) => ({ key: 'macp-error-code', value }))
60
+ : undefined));
59
61
  queue.push({ error: wrapped });
60
62
  if (resolve) {
61
63
  resolve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macp-sdk-typescript",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -56,7 +56,7 @@
56
56
  "dependencies": {
57
57
  "@grpc/grpc-js": "^1.14.3",
58
58
  "@grpc/proto-loader": "^0.8.0",
59
- "@multiagentcoordinationprotocol/proto": "^0.1.10",
59
+ "@multiagentcoordinationprotocol/proto": "^0.1.11",
60
60
  "protobufjs": "^8.6.5"
61
61
  },
62
62
  "devDependencies": {