macp-sdk-typescript 0.11.0 → 0.12.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +1 -1
  3. package/dist/agent/index.d.ts +1 -1
  4. package/dist/agent/index.js +2 -1
  5. package/dist/agent/participant.d.ts +47 -4
  6. package/dist/agent/participant.js +88 -10
  7. package/dist/agent/strategies.js +23 -7
  8. package/dist/agent/transports.d.ts +19 -1
  9. package/dist/agent/transports.js +108 -66
  10. package/dist/auth.d.ts +15 -4
  11. package/dist/auth.js +23 -7
  12. package/dist/base-session.js +13 -3
  13. package/dist/client.js +58 -15
  14. package/dist/commitment-hash.d.ts +33 -3
  15. package/dist/commitment-hash.js +39 -5
  16. package/dist/decision.d.ts +1 -0
  17. package/dist/decision.js +11 -9
  18. package/dist/envelope.js +9 -1
  19. package/dist/handoff.d.ts +1 -0
  20. package/dist/handoff.js +19 -6
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +18 -7
  23. package/dist/projections/base.d.ts +71 -14
  24. package/dist/projections/base.js +83 -12
  25. package/dist/projections/decision.js +23 -3
  26. package/dist/projections/handoff.js +39 -16
  27. package/dist/projections/proposal.js +16 -2
  28. package/dist/projections/quorum.js +13 -0
  29. package/dist/projections/task.d.ts +27 -4
  30. package/dist/projections/task.js +44 -13
  31. package/dist/proposal.d.ts +1 -0
  32. package/dist/proposal.js +2 -2
  33. package/dist/proto-registry.d.ts +69 -3
  34. package/dist/proto-registry.js +83 -5
  35. package/dist/quorum.d.ts +1 -0
  36. package/dist/quorum.js +4 -4
  37. package/dist/task.d.ts +1 -0
  38. package/dist/task.js +24 -11
  39. package/dist/types.d.ts +14 -7
  40. package/dist/validation.d.ts +14 -0
  41. package/dist/validation.js +39 -5
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +3 -2
package/dist/client.js CHANGED
@@ -47,8 +47,10 @@ const path = __importStar(require("node:path"));
47
47
  const grpc = __importStar(require("@grpc/grpc-js"));
48
48
  const protoLoader = __importStar(require("@grpc/proto-loader"));
49
49
  const auth_1 = require("./auth");
50
+ const constants_1 = require("./constants");
50
51
  const envelope_1 = require("./envelope");
51
52
  const errors_1 = require("./errors");
53
+ const logging_1 = require("./logging");
52
54
  const proto_registry_1 = require("./proto-registry");
53
55
  const validation_1 = require("./validation");
54
56
  class AsyncQueue {
@@ -110,6 +112,26 @@ function grpcStatusName(code) {
110
112
  const name = grpc.status[code];
111
113
  return typeof name === 'string' ? name : undefined;
112
114
  }
115
+ /**
116
+ * Shared by the five registry-mutation RPCs (issue #107, Python's
117
+ * `_map_registry_mutation_error`, `client.py:347-362`): a `FAILED_PRECONDITION`
118
+ * against a read-only registry is a rejection of the *call*, not a transport
119
+ * fault — surface it as `MacpAckError`, not `MacpTransportError`.
120
+ */
121
+ const REGISTRY_MUTATION_ACK_STATUSES = { FAILED_PRECONDITION: 'FAILED_PRECONDITION' };
122
+ /**
123
+ * Convert `grpc.ServiceError.metadata` (a `grpc.Metadata` instance) into the
124
+ * plain `{ key, value }[]` shape {@link MacpAckError}'s constructor expects.
125
+ * `Metadata.getMap()` returns `-bin`-suffixed keys as `Buffer` and all other
126
+ * keys as `string` — exactly the union `MacpAckError.grpcMetadata` declares.
127
+ * Returns `undefined` when the error carries no metadata (e.g. a
128
+ * locally-raised error never sent over the wire).
129
+ */
130
+ function grpcMetadataEntries(metadata) {
131
+ if (!metadata)
132
+ return undefined;
133
+ return Object.entries(metadata.getMap()).map(([key, value]) => ({ key, value }));
134
+ }
113
135
  const TIMEOUT = Symbol('stream-read-timeout');
114
136
  const STREAM_END = Symbol('stream-end');
115
137
  class MacpStream {
@@ -120,15 +142,23 @@ class MacpStream {
120
142
  constructor(call) {
121
143
  this.call = call;
122
144
  call.on('data', (chunk) => {
123
- // Support both old format (chunk.envelope) and new oneof format (chunk.response.envelope)
124
- const envelope = chunk?.response?.envelope ?? chunk?.envelope;
145
+ // `StreamSessionResponse.response` is a proto3 `oneof` (envelope | error).
146
+ // Under this file's own proto-loader options (`oneofs: true`), the
147
+ // decoded object exposes the payload directly as `chunk.envelope` /
148
+ // `chunk.error`, with `chunk.response` set to the oneof's ARM NAME as a
149
+ // *string* (`'envelope'` or `'error'`) — verified against a real
150
+ // StreamSessionResponse encode/decode roundtrip. So `chunk.response.error`
151
+ // indexes a string and is always `undefined`; it never matched a real
152
+ // decoded frame, which is why inline errors were silently dropped.
153
+ const envelope = chunk?.envelope;
125
154
  if (envelope) {
126
155
  this.queue.push(envelope);
127
156
  }
128
- else if (chunk?.response?.error) {
129
- // Inline application-level error — stream stays open
157
+ else if (chunk?.error) {
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 });
130
160
  for (const cb of this.inlineErrorCallbacks)
131
- cb(chunk.response.error);
161
+ cb(chunk.error);
132
162
  }
133
163
  });
134
164
  call.on('error', (error) => {
@@ -288,13 +318,22 @@ class MacpClient {
288
318
  const resolved = deadlineMs ?? this.defaultDeadlineMs;
289
319
  return resolved ? new Date(Date.now() + resolved) : undefined;
290
320
  }
291
- unary(method, request, auth, deadlineMs) {
321
+ unary(method, request, auth, deadlineMs, ackStatuses) {
292
322
  return new Promise((resolve, reject) => {
293
323
  const callback = (error, response) => {
294
- if (error)
295
- reject(new errors_1.MacpTransportError(error.details || error.message, grpcStatusName(error.code)));
296
- else
324
+ if (error) {
325
+ const statusName = grpcStatusName(error.code);
326
+ const mappedCode = statusName ? ackStatuses?.[statusName] : undefined;
327
+ if (mappedCode) {
328
+ reject(new errors_1.MacpAckError({ ok: false, error: { code: mappedCode, message: error.details || error.message } }, grpcMetadataEntries(error.metadata)));
329
+ }
330
+ else {
331
+ reject(new errors_1.MacpTransportError(error.details || error.message, statusName));
332
+ }
333
+ }
334
+ else {
297
335
  resolve(response);
336
+ }
298
337
  };
299
338
  const deadline = this.deadline(deadlineMs);
300
339
  const metadata = this.metadata(auth);
@@ -332,7 +371,11 @@ class MacpClient {
332
371
  }
333
372
  async send(envelope, options) {
334
373
  const auth = this.requireAuth(options?.auth);
335
- const response = await this.unary('Send', { envelope }, auth, options?.deadlineMs);
374
+ const response = await this.unary('Send', { envelope }, auth, options?.deadlineMs,
375
+ // gRPC ALREADY_EXISTS/FAILED_PRECONDITION on Send are application-level
376
+ // rejections the runtime couldn't express as a normal NACK (issue #107);
377
+ // 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 });
336
379
  const ack = response.ack;
337
380
  // Duplicate acks are success — the message was already accepted
338
381
  if (ack?.duplicate)
@@ -428,23 +471,23 @@ class MacpClient {
428
471
  'a mode without a terminal Commitment can never resolve and the runtime rejects it.');
429
472
  }
430
473
  const auth = this.requireAuth(options?.auth);
431
- return this.unary('RegisterExtMode', { modeDescriptor: descriptor }, auth, options?.deadlineMs);
474
+ return this.unary('RegisterExtMode', { modeDescriptor: descriptor }, auth, options?.deadlineMs, REGISTRY_MUTATION_ACK_STATUSES);
432
475
  }
433
476
  async unregisterExtMode(mode, options) {
434
477
  const auth = this.requireAuth(options?.auth);
435
- return this.unary('UnregisterExtMode', { mode }, auth, options?.deadlineMs);
478
+ return this.unary('UnregisterExtMode', { mode }, auth, options?.deadlineMs, REGISTRY_MUTATION_ACK_STATUSES);
436
479
  }
437
480
  async promoteMode(mode, promotedModeName = '', options) {
438
481
  const auth = this.requireAuth(options?.auth);
439
- return this.unary('PromoteMode', { mode, promotedModeName }, auth, options?.deadlineMs);
482
+ return this.unary('PromoteMode', { mode, promotedModeName }, auth, options?.deadlineMs, REGISTRY_MUTATION_ACK_STATUSES);
440
483
  }
441
484
  async registerPolicy(descriptor, options) {
442
485
  const auth = this.requireAuth(options?.auth);
443
- return this.unary('RegisterPolicy', { policyDescriptor: descriptor }, auth, options?.deadlineMs);
486
+ return this.unary('RegisterPolicy', { policyDescriptor: descriptor }, auth, options?.deadlineMs, REGISTRY_MUTATION_ACK_STATUSES);
444
487
  }
445
488
  async unregisterPolicy(policyId, options) {
446
489
  const auth = this.requireAuth(options?.auth);
447
- return this.unary('UnregisterPolicy', { policyId }, auth, options?.deadlineMs);
490
+ return this.unary('UnregisterPolicy', { policyId }, auth, options?.deadlineMs, REGISTRY_MUTATION_ACK_STATUSES);
448
491
  }
449
492
  async getPolicy(policyId, options) {
450
493
  const auth = this.requireAuth(options?.auth);
@@ -14,9 +14,24 @@ import type { CommitmentPayload } from './types';
14
14
  * that is locale-sensitive and not equivalent to code-unit ordering; it is
15
15
  * moot only because the names above are already in the right order.
16
16
  *
17
- * `supersedes` is omitted entirely when `payload.supersedes === undefined`
18
- * (D2.3) — including when the payload itself is missing/malformed, `payload
19
- * ?? {}` never manufactures a `supersedes` key. An explicit
17
+ * `supersedes` is omitted entirely when `payload.supersedes` is `undefined`
18
+ * OR `null` (D2.3) — including when the payload itself is missing/malformed,
19
+ * `payload ?? {}` never manufactures a `supersedes` key. `supersedes` is a
20
+ * proto3 optional/message-typed field: its only two wire states are present
21
+ * and absent, and `null` is the idiomatic in-language representation of
22
+ * "absent" for a message-typed field (e.g. under a `defaults: true`
23
+ * protobufjs/proto-loader decode, or a payload round-tripped through JSON).
24
+ * The comparison below is deliberately `!= null` (loose), not `!==
25
+ * undefined` — do not tighten it back; that would resurrect issue #105,
26
+ * where a `null` `supersedes` hashed as present-but-empty instead of absent,
27
+ * diverging from `macp-sdk-python`'s `HasField`-based projection for the
28
+ * same logical commitment. This is deliberately more tolerant than
29
+ * `buildCommitmentPayload` (`envelope.ts`), which still throws on an
30
+ * explicit `supersedes: null` — that function validates a caller's stated
31
+ * intent and should reject a likely mistake; this one (D3) must tolerate
32
+ * whatever a `CommitmentPayload` from any source already contains,
33
+ * including a `null` that is a legitimate "absent" from elsewhere (e.g. a
34
+ * `defaults: true` decode). The two are not meant to converge. An explicit
20
35
  * `supersedes: { sessionId: '', commitmentHash: '' }` is NOT collapsed into
21
36
  * omission; it is present with two empty-string members, `commitment_hash`
22
37
  * before `session_id` (also alphabetical).
@@ -39,3 +54,18 @@ export declare function canonicalizeCommitmentPayload(payload: CommitmentPayload
39
54
  * failure rather than being silently swallowed by a catch-all.
40
55
  */
41
56
  export declare function commitmentHash(payload: CommitmentPayload): string;
57
+ /**
58
+ * The same shape `commitmentHash` always produces: `sha256:` followed by 64
59
+ * lowercase hex digits. Non-throwing counterpart to `validateCommitmentHash`
60
+ * (`src/validation.ts`, which throws `MacpSessionError` on mismatch) — use
61
+ * this predicate where a boolean fits better than a thrown exception, e.g.
62
+ * filtering a list or a non-fatal check. Both share one regex family
63
+ * (`/^sha256:[0-9a-f]{64}$/`); keep them in sync if either changes.
64
+ *
65
+ * Never throws, for any input. `$` without the `m` flag anchors to the
66
+ * absolute end of the string (not "before a trailing newline", unlike some
67
+ * regex engines) — so a trailing `\n` after an otherwise-valid hash already
68
+ * fails to match with no special-casing needed, matching Python's
69
+ * `fullmatch` semantics for this pattern.
70
+ */
71
+ export declare function isCanonicalCommitmentHash(value: string): boolean;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.canonicalizeCommitmentPayload = canonicalizeCommitmentPayload;
4
4
  exports.commitmentHash = commitmentHash;
5
+ exports.isCanonicalCommitmentHash = isCanonicalCommitmentHash;
5
6
  /**
6
7
  * RFC-MACP-0013 canonical commitment hashing.
7
8
  *
@@ -141,9 +142,24 @@ function jcsBoolean(value) {
141
142
  * that is locale-sensitive and not equivalent to code-unit ordering; it is
142
143
  * moot only because the names above are already in the right order.
143
144
  *
144
- * `supersedes` is omitted entirely when `payload.supersedes === undefined`
145
- * (D2.3) — including when the payload itself is missing/malformed, `payload
146
- * ?? {}` never manufactures a `supersedes` key. An explicit
145
+ * `supersedes` is omitted entirely when `payload.supersedes` is `undefined`
146
+ * OR `null` (D2.3) — including when the payload itself is missing/malformed,
147
+ * `payload ?? {}` never manufactures a `supersedes` key. `supersedes` is a
148
+ * proto3 optional/message-typed field: its only two wire states are present
149
+ * and absent, and `null` is the idiomatic in-language representation of
150
+ * "absent" for a message-typed field (e.g. under a `defaults: true`
151
+ * protobufjs/proto-loader decode, or a payload round-tripped through JSON).
152
+ * The comparison below is deliberately `!= null` (loose), not `!==
153
+ * undefined` — do not tighten it back; that would resurrect issue #105,
154
+ * where a `null` `supersedes` hashed as present-but-empty instead of absent,
155
+ * diverging from `macp-sdk-python`'s `HasField`-based projection for the
156
+ * same logical commitment. This is deliberately more tolerant than
157
+ * `buildCommitmentPayload` (`envelope.ts`), which still throws on an
158
+ * explicit `supersedes: null` — that function validates a caller's stated
159
+ * intent and should reject a likely mistake; this one (D3) must tolerate
160
+ * whatever a `CommitmentPayload` from any source already contains,
161
+ * including a `null` that is a legitimate "absent" from elsewhere (e.g. a
162
+ * `defaults: true` decode). The two are not meant to converge. An explicit
147
163
  * `supersedes: { sessionId: '', commitmentHash: '' }` is NOT collapsed into
148
164
  * omission; it is present with two empty-string members, `commitment_hash`
149
165
  * before `session_id` (also alphabetical).
@@ -167,9 +183,10 @@ function canonicalizeCommitmentPayload(payload) {
167
183
  `"policy_version":${jcsString(p.policyVersion ?? '')}`,
168
184
  `"reason":${jcsString(p.reason)}`,
169
185
  ];
170
- if (p.supersedes !== undefined) {
186
+ if (p.supersedes != null) {
171
187
  // Optional chaining tolerates `supersedes` being present but not
172
- // actually an object (null, a primitive, ...) on a malformed payload —
188
+ // actually an object (a primitive: `5`, `'x'`, `true`, ... — `null` is
189
+ // excluded above, it never reaches this branch) on a malformed payload —
173
190
  // it never throws, it just yields `undefined` for the sub-fields, which
174
191
  // `jcsString` then coerces to `""` the same way it handles any other
175
192
  // missing string field.
@@ -196,3 +213,20 @@ function commitmentHash(payload) {
196
213
  const digest = (0, node_crypto_1.createHash)('sha256').update(Buffer.from(preimage, 'utf8')).digest('hex');
197
214
  return `sha256:${digest}`;
198
215
  }
216
+ /**
217
+ * The same shape `commitmentHash` always produces: `sha256:` followed by 64
218
+ * lowercase hex digits. Non-throwing counterpart to `validateCommitmentHash`
219
+ * (`src/validation.ts`, which throws `MacpSessionError` on mismatch) — use
220
+ * this predicate where a boolean fits better than a thrown exception, e.g.
221
+ * filtering a list or a non-fatal check. Both share one regex family
222
+ * (`/^sha256:[0-9a-f]{64}$/`); keep them in sync if either changes.
223
+ *
224
+ * Never throws, for any input. `$` without the `m` flag anchors to the
225
+ * absolute end of the string (not "before a trailing newline", unlike some
226
+ * regex engines) — so a trailing `\n` after an otherwise-valid hash already
227
+ * fails to match with no special-casing needed, matching Python's
228
+ * `fullmatch` semantics for this pattern.
229
+ */
230
+ function isCanonicalCommitmentHash(value) {
231
+ return /^sha256:[0-9a-f]{64}$/.test(value);
232
+ }
@@ -32,6 +32,7 @@ export declare class DecisionSession {
32
32
  }[];
33
33
  maxSuspendMs?: number;
34
34
  sender?: string;
35
+ auth?: AuthConfig;
35
36
  }): Promise<Ack>;
36
37
  propose(input: DecisionProposalPayload & {
37
38
  sender?: string;
package/dist/decision.js CHANGED
@@ -60,10 +60,10 @@ class DecisionSession {
60
60
  mode: constants_1.MODE_DECISION,
61
61
  messageType: 'SessionStart',
62
62
  sessionId: this.sessionId,
63
- sender: this.senderFor(input.sender),
63
+ sender: this.senderFor(input.sender, input.auth),
64
64
  payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'SessionStart', (0, envelope_1.toProtoPayload)(payload)),
65
65
  });
66
- return this.sendAndTrack(envelope, this.auth);
66
+ return this.sendAndTrack(envelope, input.auth ?? this.auth);
67
67
  }
68
68
  async propose(input) {
69
69
  (0, validation_1.validateRequiredField)('proposalId', input.proposalId);
@@ -79,39 +79,41 @@ class DecisionSession {
79
79
  }
80
80
  async evaluate(input) {
81
81
  (0, validation_1.validateRequiredField)('proposalId', input.proposalId);
82
- (0, validation_1.validateRecommendation)(input.recommendation);
82
+ // Use the validators' returned normalized values (issue #108.3) — they
83
+ // return a normalized string precisely so callers put the wire-canonical
84
+ // form on the envelope, not whatever case the caller happened to pass.
85
+ const recommendation = (0, validation_1.validateRecommendation)(input.recommendation);
83
86
  (0, validation_1.validateConfidence)(input.confidence);
84
87
  const envelope = (0, envelope_1.buildEnvelope)({
85
88
  mode: constants_1.MODE_DECISION,
86
89
  messageType: 'Evaluation',
87
90
  sessionId: this.sessionId,
88
91
  sender: this.senderFor(input.sender, input.auth),
89
- payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'Evaluation', (0, envelope_1.toProtoPayload)(input)),
92
+ payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'Evaluation', (0, envelope_1.toProtoPayload)({ ...input, recommendation })),
90
93
  });
91
94
  return this.sendAndTrack(envelope, input.auth);
92
95
  }
93
96
  async raiseObjection(input) {
94
97
  (0, validation_1.validateRequiredField)('proposalId', input.proposalId);
95
- if (input.severity)
96
- (0, validation_1.validateSeverity)(input.severity);
98
+ const severity = input.severity ? (0, validation_1.validateSeverity)(input.severity) : input.severity;
97
99
  const envelope = (0, envelope_1.buildEnvelope)({
98
100
  mode: constants_1.MODE_DECISION,
99
101
  messageType: 'Objection',
100
102
  sessionId: this.sessionId,
101
103
  sender: this.senderFor(input.sender, input.auth),
102
- payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'Objection', (0, envelope_1.toProtoPayload)(input)),
104
+ payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'Objection', (0, envelope_1.toProtoPayload)({ ...input, severity })),
103
105
  });
104
106
  return this.sendAndTrack(envelope, input.auth);
105
107
  }
106
108
  async vote(input) {
107
109
  (0, validation_1.validateRequiredField)('proposalId', input.proposalId);
108
- (0, validation_1.validateVote)(input.vote);
110
+ const vote = (0, validation_1.validateVote)(input.vote);
109
111
  const envelope = (0, envelope_1.buildEnvelope)({
110
112
  mode: constants_1.MODE_DECISION,
111
113
  messageType: 'Vote',
112
114
  sessionId: this.sessionId,
113
115
  sender: this.senderFor(input.sender, input.auth),
114
- payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'Vote', (0, envelope_1.toProtoPayload)(input)),
116
+ payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_DECISION, 'Vote', (0, envelope_1.toProtoPayload)({ ...input, vote })),
115
117
  });
116
118
  return this.sendAndTrack(envelope, input.auth);
117
119
  }
package/dist/envelope.js CHANGED
@@ -92,7 +92,15 @@ function buildCommitmentPayload(input) {
92
92
  if (typeof input.supersedes !== 'object' || input.supersedes === null) {
93
93
  // `String()`, not `JSON.stringify()`: the latter throws on a bigint,
94
94
  // which would replace this deliberate `MacpSessionError` with an
95
- // unrelated `TypeError` for that one input shape.
95
+ // unrelated `TypeError` for that one input shape. Deliberately
96
+ // stricter here than `commitmentHash`'s D3 "never throw" contract
97
+ // (issue #105): this is a constructor validating a caller's own
98
+ // explicit intent, not a hasher that must tolerate any
99
+ // already-materialized `CommitmentPayload` from any source (including
100
+ // one decoded elsewhere with `defaults: true`, where a `null` is a
101
+ // legitimate "absent" reaching the hasher, not a mistake reaching the
102
+ // builder) — so a `null` here throws instead of being silently
103
+ // treated as absent.
96
104
  throw new errors_1.MacpSessionError(`supersedes must be a CommitmentRef object, got: ${String(input.supersedes)}`);
97
105
  }
98
106
  (0, validation_1.validateCommitmentHash)(input.supersedes.commitmentHash);
package/dist/handoff.d.ts CHANGED
@@ -32,6 +32,7 @@ export declare class HandoffSession {
32
32
  }[];
33
33
  maxSuspendMs?: number;
34
34
  sender?: string;
35
+ auth?: AuthConfig;
35
36
  }): Promise<Ack>;
36
37
  offer(input: HandoffOfferPayload & {
37
38
  sender?: string;
package/dist/handoff.js CHANGED
@@ -60,10 +60,10 @@ class HandoffSession {
60
60
  mode: constants_1.MODE_HANDOFF,
61
61
  messageType: 'SessionStart',
62
62
  sessionId: this.sessionId,
63
- sender: this.senderFor(input.sender),
63
+ sender: this.senderFor(input.sender, input.auth),
64
64
  payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_HANDOFF, 'SessionStart', (0, envelope_1.toProtoPayload)(payload)),
65
65
  });
66
- return this.sendAndTrack(envelope, this.auth);
66
+ return this.sendAndTrack(envelope, input.auth ?? this.auth);
67
67
  }
68
68
  async offer(input) {
69
69
  (0, validation_1.validateRequiredField)('handoffId', input.handoffId);
@@ -80,40 +80,53 @@ class HandoffSession {
80
80
  }
81
81
  async addContext(input) {
82
82
  (0, validation_1.validateRequiredField)('handoffId', input.handoffId);
83
+ // Issue #108.6/4f: default contentType, matching Python (`handoff.py:210`).
84
+ // Deliberately `||`, not the `??` `offer()` above uses for `scope`: an
85
+ // explicit empty string must also fall back to the default here.
86
+ const contextInput = { ...input, contentType: input.contentType || 'application/octet-stream' };
83
87
  const envelope = (0, envelope_1.buildEnvelope)({
84
88
  mode: constants_1.MODE_HANDOFF,
85
89
  messageType: 'HandoffContext',
86
90
  sessionId: this.sessionId,
87
91
  sender: this.senderFor(input.sender, input.auth),
88
- payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_HANDOFF, 'HandoffContext', (0, envelope_1.toProtoPayload)(input)),
92
+ payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_HANDOFF, 'HandoffContext', (0, envelope_1.toProtoPayload)(contextInput)),
89
93
  });
90
94
  return this.sendAndTrack(envelope, input.auth);
91
95
  }
92
96
  async acceptHandoff(input) {
93
97
  (0, validation_1.validateRequiredField)('handoffId', input.handoffId);
98
+ const sender = this.senderFor(input.sender, input.auth);
94
99
  // `implicit` is a runtime-emitted-only flag (RFC-MACP-0010 §5.1); the
95
100
  // runtime rejects client-submitted accepts with implicit=true. Strip it so
96
101
  // a caller can never produce a rejected envelope, regardless of what they
97
102
  // pass — belt and braces with the JSDoc "read-only" contract on the type.
98
103
  const rest = { ...input };
99
104
  delete rest.implicit;
105
+ // Issue #108.5/4e: default acceptedBy to the resolved sender. Applied to
106
+ // `rest` (the object actually encoded below), NOT to `input` — unlike
107
+ // every other action here, this method doesn't route through
108
+ // `toProtoPayload(input)`, so a fallback written against `input` would be
109
+ // silently dropped.
110
+ rest.acceptedBy = input.acceptedBy || sender;
100
111
  const envelope = (0, envelope_1.buildEnvelope)({
101
112
  mode: constants_1.MODE_HANDOFF,
102
113
  messageType: 'HandoffAccept',
103
114
  sessionId: this.sessionId,
104
- sender: this.senderFor(input.sender, input.auth),
115
+ sender,
105
116
  payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_HANDOFF, 'HandoffAccept', rest),
106
117
  });
107
118
  return this.sendAndTrack(envelope, input.auth);
108
119
  }
109
120
  async decline(input) {
110
121
  (0, validation_1.validateRequiredField)('handoffId', input.handoffId);
122
+ const sender = this.senderFor(input.sender, input.auth);
123
+ const declinedBy = input.declinedBy || sender;
111
124
  const envelope = (0, envelope_1.buildEnvelope)({
112
125
  mode: constants_1.MODE_HANDOFF,
113
126
  messageType: 'HandoffDecline',
114
127
  sessionId: this.sessionId,
115
- sender: this.senderFor(input.sender, input.auth),
116
- payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_HANDOFF, 'HandoffDecline', (0, envelope_1.toProtoPayload)(input)),
128
+ sender,
129
+ payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_HANDOFF, 'HandoffDecline', (0, envelope_1.toProtoPayload)({ ...input, declinedBy })),
117
130
  });
118
131
  return this.sendAndTrack(envelope, input.auth);
119
132
  }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  export { Auth, type AuthConfig, type BearerAuthOptions } from './auth';
2
2
  export * from './base-session';
3
3
  export * from './client';
4
- export { commitmentHash } from './commitment-hash';
4
+ export { commitmentHash, isCanonicalCommitmentHash } from './commitment-hash';
5
5
  export * from './constants';
6
6
  export * from './decision';
7
7
  export * from './envelope';
8
8
  export * from './errors';
9
9
  export * from './handoff';
10
- export * from './logging';
10
+ export { configureLogging, logger, type LogLevel, type LogSink } from './logging';
11
11
  export * from './policy';
12
12
  export * from './projections';
13
13
  export * from './proposal';
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.commitmentHash = exports.Auth = void 0;
39
+ exports.agent = exports.VERSION = exports.logger = exports.configureLogging = 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,19 +46,30 @@ 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 only `commitmentHash`. `canonicalizeCommitmentPayload`
50
- // is the projection/JCS half of the algorithm — importable from the
51
- // `./commitment-hash` submodule (as the vector-runner tests do) but kept off
52
- // the top-level surface to match python-sdk's public API, which does not
53
- // expose its `canonical_projection` equivalent either.
49
+ // Commitment hash: export `commitmentHash` and `isCanonicalCommitmentHash`.
50
+ // `canonicalizeCommitmentPayload` is the projection/JCS half of the
51
+ // algorithm — importable from the `./commitment-hash` submodule (as the
52
+ // vector-runner tests do) but kept off the top-level surface to match
53
+ // python-sdk's public API, which does not expose its `canonical_projection`
54
+ // equivalent either.
54
55
  var commitment_hash_1 = require("./commitment-hash");
55
56
  Object.defineProperty(exports, "commitmentHash", { enumerable: true, get: function () { return commitment_hash_1.commitmentHash; } });
57
+ Object.defineProperty(exports, "isCanonicalCommitmentHash", { enumerable: true, get: function () { return commitment_hash_1.isCanonicalCommitmentHash; } });
56
58
  __exportStar(require("./constants"), exports);
57
59
  __exportStar(require("./decision"), exports);
58
60
  __exportStar(require("./envelope"), exports);
59
61
  __exportStar(require("./errors"), exports);
60
62
  __exportStar(require("./handoff"), exports);
61
- __exportStar(require("./logging"), exports);
63
+ // Logging: export the public surface only. `_resetLoggingForTests` is a
64
+ // test-only reset helper — excluded here (a one-hop wildcard, unlike
65
+ // `PROJECTION_ANOMALY_FIELD_ORDER`'s two-hop chain via `./projections`,
66
+ // which cannot be excluded this way). `package.json`'s `exports` map
67
+ // exposes only `.`, so this makes it unreachable from the published
68
+ // package, not merely un-barrelled — accepted, since it is test-only by
69
+ // name and by purpose.
70
+ var logging_1 = require("./logging");
71
+ Object.defineProperty(exports, "configureLogging", { enumerable: true, get: function () { return logging_1.configureLogging; } });
72
+ Object.defineProperty(exports, "logger", { enumerable: true, get: function () { return logging_1.logger; } });
62
73
  __exportStar(require("./policy"), exports);
63
74
  __exportStar(require("./projections"), exports);
64
75
  __exportStar(require("./proposal"), exports);
@@ -4,9 +4,11 @@ import type { Envelope } from '../types';
4
4
  * The kind of cardinality anomaly a projection recorded while replaying an
5
5
  * accepted transcript. Cross-SDK frozen contract (`macp-sdk-python` adopted
6
6
  * the identical field set, snake_case there) — do not add a kind without
7
- * cross-SDK agreement.
7
+ * cross-SDK agreement. `duplicate_task_accept`/`settled_handoff` were added
8
+ * under exactly that agreement (issue #126/#128; `macp-sdk-python` landed its
9
+ * half in PR #95, merged `32c6437`).
8
10
  */
9
- export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot';
11
+ export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot' | 'duplicate_task_accept' | 'settled_handoff';
10
12
  /**
11
13
  * A recorded observation that a second distinct `Vote` from this sender for
12
14
  * this proposal (RFC-MACP-0007 §5.3), or a second distinct ballot across
@@ -31,7 +33,8 @@ export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot';
31
33
  * `macp-sdk-python`). Do not add, rename, or remove a field without
32
34
  * cross-SDK agreement.
33
35
  *
34
- * Only `DecisionProjection` and `QuorumProjection` populate this today.
36
+ * `DecisionProjection`, `QuorumProjection`, `TaskProjection`, and
37
+ * `HandoffProjection` populate this today.
35
38
  */
36
39
  export interface ProjectionAnomaly {
37
40
  kind: ProjectionAnomalyKind;
@@ -39,7 +42,10 @@ export interface ProjectionAnomaly {
39
42
  messageType: string;
40
43
  messageId: string;
41
44
  sender: string;
42
- /** `proposal_id` (Decision) or `request_id` (Quorum) the duplicate targeted. */
45
+ /**
46
+ * `proposal_id` (Decision), `request_id` (Quorum), `task_id` (Task), or
47
+ * `handoff_id` (Handoff) the anomaly targeted.
48
+ */
43
49
  subjectId: string;
44
50
  /**
45
51
  * Human-readable detail. For a cross-type Quorum duplicate, `messageType`
@@ -48,12 +54,54 @@ export interface ProjectionAnomaly {
48
54
  */
49
55
  detail: string;
50
56
  }
57
+ /**
58
+ * The four `ProjectionAnomalyKind` values as named constants — all four are
59
+ * cross-SDK contract, literally pinned by the spec repo's
60
+ * `schemas/parity/contract.json` (`projection_anomaly.kinds`, `contract_version`
61
+ * 1.2.0 — `ANOMALY_DUPLICATE_TASK_ACCEPT`/`ANOMALY_SETTLED_HANDOFF` landed there
62
+ * under issue #126/#128, after this SDK's PR #134 completed the agreement
63
+ * `macp-sdk-python`'s PR #95 started). `satisfies` links each constant to the
64
+ * union above at compile time, so a drifted value fails `npm run check` before
65
+ * it can ever reach `make verify-parity`.
66
+ */
67
+ export declare const ANOMALY_DUPLICATE_VOTE = "duplicate_vote";
68
+ export declare const ANOMALY_DUPLICATE_BALLOT = "duplicate_ballot";
69
+ export declare const ANOMALY_DUPLICATE_TASK_ACCEPT = "duplicate_task_accept";
70
+ export declare const ANOMALY_SETTLED_HANDOFF = "settled_handoff";
71
+ /**
72
+ * Runtime field order for `ProjectionAnomaly`, with the same frozen-set
73
+ * guarantee as `_ProjectionAnomalyFieldSetIsFrozen` above, expressed as a
74
+ * value rather than only a type. The spec repo's
75
+ * `schemas/parity/contract.json` (`projection_anomaly.fields`) pins this
76
+ * field order as a runtime JSON array, so this SDK needs its own runtime
77
+ * array to assert against it (see `tests/parity/contract.test.ts`).
78
+ *
79
+ * **Public, not hidden — corrected during Phase 2's own verification round.**
80
+ * An earlier revision of this docblock claimed this constant was
81
+ * "deliberately NOT added to the public barrel," on the (wrong) assumption
82
+ * that it would follow `canonicalizeCommitmentPayload`'s precedent in
83
+ * `commitment-hash.ts`. That precedent doesn't transfer: `commitment-hash.ts`
84
+ * is barrel-exported via a *named* line in `src/index.ts`
85
+ * (`export { commitmentHash, isCanonicalCommitmentHash } from
86
+ * './commitment-hash'`), which can omit a name on purpose. This module is
87
+ * barrel-exported via `src/projections.ts`'s `export * from
88
+ * './projections/base'` → `src/index.ts`'s `export * from './projections'`
89
+ * — a wildcard re-export chain has no way to omit one name, so this constant
90
+ * reaches `dist/index.d.ts` and the runtime `require('macp-sdk-typescript')`
91
+ * surface regardless of intent (verified: `node -e "require('./dist/index.js')
92
+ * .PROJECTION_ANOMALY_FIELD_ORDER"` prints the array). This is harmless —
93
+ * a small readonly string tuple is no more a surface-area risk than
94
+ * `STANDARD_MODES` — so the fix is documenting reality, not restructuring
95
+ * the export chain to force exclusion. Phase 3's public-API snapshot
96
+ * correctly captures this symbol as part of today's real surface.
97
+ */
98
+ export declare const PROJECTION_ANOMALY_FIELD_ORDER: readonly ["kind", "mode", "messageType", "messageId", "sender", "subjectId", "detail"];
51
99
  /**
52
100
  * Abstract base for in-process mode-state tracking — parity with python-sdk's
53
101
  * `macp_sdk.base_projection.BaseProjection`. Maintains a shared transcript,
54
102
  * phase string, and commitment payload; subclasses override `applyMode`
55
- * to handle mode-specific envelopes. `SessionStart` and `Commitment` are
56
- * handled here so custom modes get the common lifecycle for free.
103
+ * to handle mode-specific envelopes. `Commitment` is handled here so custom
104
+ * modes get the common lifecycle for free.
57
105
  */
58
106
  export declare abstract class BaseProjection {
59
107
  /**
@@ -84,19 +132,28 @@ export declare abstract class BaseProjection {
84
132
  /**
85
133
  * Cardinality anomalies recorded while replaying this projection's
86
134
  * accepted transcript (e.g. a duplicate vote or ballot from the same
87
- * sender). See `ProjectionAnomaly`. Empty unless a subclass calls
88
- * `recordAnomaly`. NOTE: as of this SDK's built-in modes, nothing calls
89
- * `recordAnomaly` — `DecisionProjection` and `QuorumProjection` do not
90
- * extend `BaseProjection` (see the class docblock) and inline their own
91
- * two lines instead. This field and `recordAnomaly` exist for ext-mode
92
- * `BaseProjection` subclasses outside this repo.
135
+ * sender, a duplicate `TaskAccept` competing for an already-held assignee
136
+ * slot, or a `HandoffAccept`/`HandoffDecline` for an already-settled
137
+ * handoff). See `ProjectionAnomaly`. Empty unless a subclass calls
138
+ * `recordAnomaly`. **Corrected 2026-09-25** (this note previously said
139
+ * nothing calls `recordAnomaly` because `DecisionProjection`/
140
+ * `QuorumProjection` didn't extend `BaseProjection` — that was true before
141
+ * issue #91's refactor, and is stale now): both `DecisionProjection`
142
+ * (`decision.ts`) and `QuorumProjection` (`quorum.ts`) extend
143
+ * `BaseProjection` today and DO call the inherited `recordAnomaly` when
144
+ * they discard a duplicate vote/ballot — they just pass a literal
145
+ * `'duplicate_vote'`/`'duplicate_ballot'` string for `kind` rather than
146
+ * this file's `ANOMALY_DUPLICATE_VOTE`/`ANOMALY_DUPLICATE_BALLOT`
147
+ * constants (referencing those constants instead is optional cleanup, not
148
+ * required by anything in this repo). This field and `recordAnomaly` are
149
+ * no longer ext-mode-only — any subclass, built-in or ext-mode, uses them.
93
150
  */
94
151
  readonly anomalies: ProjectionAnomaly[];
95
152
  protected abstract readonly mode: string;
96
153
  /**
97
154
  * True once at least one `ProjectionAnomaly` has been recorded — i.e. this
98
- * projection observed and discarded a duplicate vote or ballot from the
99
- * same sender (see `anomalies` above). This getter is exactly
155
+ * projection observed and discarded a cardinality anomaly (see `anomalies`
156
+ * above for the full set of kinds). This getter is exactly
100
157
  * `anomalies.length > 0`; it never asserts "this transcript violates the
101
158
  * spec" (see `ProjectionAnomaly`'s docblock, and `applyEnvelope`'s "Input
102
159
  * contract: accepted-only" section above, for why a projection cannot make