macp-sdk-typescript 0.14.0 → 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,6 +4,83 @@ 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.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))
15
+
16
+
17
+ ### Bug Fixes
18
+
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))
20
+
21
+ ## [0.14.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.14.0...v0.14.1) (2026-10-03)
22
+
23
+ ### Added
24
+
25
+ - **`TaskProjection.rejections: TaskRejectRecord[]`** (issue #151; a
26
+ cross-SDK parity audit against `macp-sdk-python`'s `TaskRejectRecord`,
27
+ `src/macp_sdk/task.py:53-58`). `TaskReject` now appends an unconditional
28
+ audit record — `taskId`, `assignee`, `reason?`, `sender` (from the
29
+ envelope, not the payload) — matching the convention already established
30
+ for `updates`/`completions`/`failures`. A `TaskReject` for an unknown
31
+ `taskId` still records a `rejections` entry (no crash, no silent drop) but
32
+ creates no `tasks` entry, unchanged from today. Deliberately does not fall
33
+ back `assignee` to the envelope sender the way Python's `p.assignee or
34
+ envelope.sender` does — this SDK's own sibling `TaskComplete`/`TaskFail`
35
+ handlers spread the payload's `assignee` as-is with no such fallback, and
36
+ intra-SDK consistency with that established pattern takes precedence here.
37
+
38
+ ### Fixed
39
+
40
+ - **`BaseProjection` phase no longer regresses once `Committed`, and
41
+ `DecisionProjection` no longer lets a replayed `Proposal` rewind phase
42
+ from `Voting` back to `Evaluation` after voting has begun** (issue #150,
43
+ issue #153; found by a cross-SDK parity audit against `macp-sdk-python`'s
44
+ `_set_phase()`, `src/macp_sdk/base_projection.py:289-313`). All five
45
+ built-in mode projections now route every phase write through a new
46
+ shared, protected `setPhase()` choke point on `BaseProjection` that
47
+ no-ops once `phase === 'Committed'`, instead of assigning `this.phase`
48
+ directly at each of the (previously) 12 call sites across
49
+ `decision.ts`/`proposal.ts`/`task.ts`/`quorum.ts`/`handoff.ts` — only
50
+ `decision.ts`'s own Vote-branch guard protected this before. RFC-MACP-0007
51
+ §5 rule 6 separately requires a runtime to reject any
52
+ `Proposal`/`Evaluation`/`Objection` after the first accepted `Vote`;
53
+ `DecisionProjection`'s `Proposal` handler now guards on the pre-
54
+ `Evaluation` phase value (`if (this.phase === 'Proposal')
55
+ this.setPhase('Evaluation')`) so a replayed/retried `Proposal` (same
56
+ `proposalId`, a distinct `messageId` that still passes
57
+ `applyEnvelope`'s `message_id` dedup gate) can no longer rewind `phase`
58
+ back to `'Evaluation'` once voting has begun. `BaseProjection.
59
+ applyEnvelope`'s own direct `this.phase = 'Committed'` assignment for a
60
+ `Commitment` envelope is deliberately left untouched — exact parity with
61
+ Python's own `_set_phase()` docstring, which documents the same bypass.
62
+ - **`buildDecisionPolicy` now rejects a `+Infinity` voting weight** (issue
63
+ #152; found by a cross-SDK parity audit against `macp-sdk-python`'s
64
+ `math.isfinite` guard, `src/macp_sdk/policy.py:186`). The per-weight
65
+ validation guard changed from `weight <= 0 || Number.isNaN(weight)` to
66
+ `weight <= 0 || !Number.isFinite(weight)` — `!Number.isFinite` is `true`
67
+ for `NaN`, `+Infinity`, and `-Infinity` alike, closing the `+Infinity` gap
68
+ the old `Number.isNaN`-only check missed (a `+Infinity` weight serializes
69
+ to JSON `null`, which is schema-invalid against
70
+ `decision-rules.schema.json`). `-Infinity`/`NaN` continue to throw,
71
+ unchanged.
72
+ - **`watchers.ts`'s 5 "stream ended before receiving a ..." sites now throw
73
+ `MacpTransportError` instead of a bare `Error`** (issue #154; parity with
74
+ `macp-sdk-python` PR #138). `ModeRegistryWatcher.nextChange()`,
75
+ `RootsWatcher.nextChange()`, `SignalWatcher.nextSignal()`,
76
+ `PolicyWatcher.nextChange()`, and `SessionLifecycleWatcher.nextChange()`
77
+ each threw a bare `Error` when their stream ended before yielding a value;
78
+ a caller's single `catch (e) { if (e instanceof MacpSdkError) ... }` now
79
+ covers a cleanly-ended watch stream alongside every other SDK failure.
80
+ Message strings are unchanged byte-for-byte; `error.code` remains
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))
83
+
7
84
  ## [0.14.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.13.0...v0.14.0) (2026-10-01)
8
85
 
9
86
 
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>;