macp-sdk-typescript 0.16.1 → 0.17.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,26 @@ 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.17.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.16.1...v0.17.0) (2026-10-18)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * **policy:** an untyped (JS/JSON) caller passing an off-domain rule value used to get a descriptor that a pre-0.9 runtime registered. That call now throws MacpSessionError at build time.
13
+ * **task:** a TaskReject sent before any TaskAccept no longer sets TaskRecord.status to 'rejected'; the task stays 'requested' and remains in activeTasks(). TaskRejectRecord gains a required whileAssigned field.
14
+
15
+ ### Features
16
+
17
+ * **handoff:** expose declined targets and the rule 3a re-offer bar ([#181](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/181)) ([dc61e08](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/dc61e08a552a729c6df9aa0dc19c31abfacd3189))
18
+ * **policy:** refuse every off-domain rule value the 0.9 runtime enforces ([#183](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/183)) ([9afd316](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/9afd3167117dd99baadabb18f3199bc0140ae0ad))
19
+ * **policy:** x- extension and _/$ annotation rules keys; policyRuleExtensions() ([#184](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/184)) ([84b3a14](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/84b3a14c2a414ebb7ff1042f5999ca17d961eab4))
20
+ * **quorum:** effectiveQuorumThreshold and a policy-override bar for the readiness helpers ([#182](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/182)) ([63a00c4](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/63a00c459f676b93cba66c1a53924074c297c30f))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **task:** model a pre-assignment TaskReject as a personal decline (rule 2a) ([#179](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/179)) ([2d56c32](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/2d56c3290c9a77557fdbaa11ab8003498178b514))
26
+
7
27
  ## [0.16.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.16.0...v0.16.1) (2026-10-18)
8
28
 
9
29
 
package/README.md CHANGED
@@ -467,6 +467,19 @@ npm run test:integration
467
467
  docker rm -f macp-runtime-test
468
468
  ```
469
469
 
470
+ ## Runtime Compatibility
471
+
472
+ This SDK requires **macp-runtime ≥ 0.9.0**. CI gates every PR on the
473
+ integration suite against `macp-runtime:0.9.0`, the same pin as
474
+ `macp-sdk-python`, and runs a non-blocking canary against `:latest`. Against
475
+ an older runtime, the 0.9.0 behaviors the SDK models will not match. Examples:
476
+
477
+ - the quorum policy-threshold override (`effectiveQuorumThreshold`);
478
+ - task rule 2a personal declines;
479
+ - the handoff rule 3a re-offer bar;
480
+ - policy rule-domain admission and `ruleExtensions`;
481
+ - `macp-error-code` statuses.
482
+
470
483
  ## Runtime Boundary
471
484
 
472
485
  This SDK is a **client** for the MACP Rust runtime. The runtime handles session state, message ordering, deduplication, TTL enforcement, and mode-specific validation. The SDK provides typed helpers for building and sending envelopes, and local projections for tracking state client-side.
package/dist/policy.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PolicyDescriptor } from './types';
1
+ import type { InitializeResult, PolicyDescriptor } from './types';
2
2
  export interface CommitmentRules {
3
3
  authority?: 'initiator_only' | 'any_participant' | 'designated_role';
4
4
  /**
@@ -128,7 +128,40 @@ export interface HandoffPolicyRulesInput {
128
128
  acceptance?: HandoffAcceptanceRules;
129
129
  commitment?: CommitmentRules;
130
130
  }
131
- export interface DecisionPolicyOptions {
131
+ /** `/properties/commitment/properties/authority` (all five modes). */
132
+ export declare const COMMITMENT_AUTHORITIES: ReadonlySet<NonNullable<CommitmentRules['authority']>>;
133
+ /** quorum-rules `/properties/abstention/properties/interpretation`. */
134
+ export declare const ABSTENTION_INTERPRETATIONS: ReadonlySet<NonNullable<AbstentionRules['interpretation']>>;
135
+ /** proposal-rules `/properties/acceptance/properties/criterion`. */
136
+ export declare const PROPOSAL_ACCEPTANCE_CRITERIA: ReadonlySet<NonNullable<ProposalAcceptanceRules['criterion']>>;
137
+ /** decision-rules `/properties/objection_handling/properties/critical_objection_action`. */
138
+ export declare const CRITICAL_OBJECTION_ACTIONS: ReadonlySet<NonNullable<ObjectionHandlingRules['criticalObjectionAction']>>;
139
+ /**
140
+ * Extra top-level `rules` keys every builder accepts (RFC-MACP-0012 §4),
141
+ * emitted after the standard sections in insertion order. Omitted or empty,
142
+ * the `rules` bytes are exactly what they were before these existed.
143
+ */
144
+ export interface PolicyRuleExtras {
145
+ /**
146
+ * `x-` vendor-extension sections, e.g. `{ 'x-com.acme.audit': { level: 2 } }`.
147
+ * Keys must match `^x-[a-z0-9]+(\.[a-z0-9-]+)+$` and values must be objects.
148
+ * The runtime admits a key only if an embedder registered it (see
149
+ * `policyRuleExtensions()`; the stock server registers none), so the builder
150
+ * checks the key's shape, not whether this runtime will admit it.
151
+ */
152
+ extensions?: Record<string, Record<string, unknown>>;
153
+ /** `_`- or `$`-prefixed annotation keys, any JSON value; the runtime ignores them. */
154
+ annotations?: Record<string, unknown>;
155
+ }
156
+ /**
157
+ * The `x-` rule-extension keys a runtime advertises
158
+ * (`capabilities.policyRegistry.ruleExtensions`), or `[]` when it advertises
159
+ * none, including runtimes older than 0.9.0. Advisory: a descriptor carrying
160
+ * an unlisted key is refused at RegisterPolicy, but admission is what
161
+ * decides. Accepts the `initialize()` result or its `capabilities`.
162
+ */
163
+ export declare function policyRuleExtensions(source: InitializeResult | InitializeResult['capabilities'] | null | undefined): string[];
164
+ export interface DecisionPolicyOptions extends PolicyRuleExtras {
132
165
  /**
133
166
  * RFC-MACP-0012 rule schema version the runtime evaluates this policy
134
167
  * under (validated, never re-validated, at admission -- RFC-MACP-0012 §8
@@ -157,7 +190,37 @@ export interface DecisionPolicyOptions {
157
190
  schemaVersion?: 1 | 2 | 3;
158
191
  }
159
192
  export declare function buildDecisionPolicy(policyId: string, description: string, rules: DecisionPolicyRulesInput, options?: DecisionPolicyOptions): PolicyDescriptor;
160
- export declare function buildQuorumPolicy(policyId: string, description: string, rules: QuorumPolicyRulesInput): PolicyDescriptor;
161
- export declare function buildProposalPolicy(policyId: string, description: string, rules: ProposalPolicyRulesInput): PolicyDescriptor;
162
- export declare function buildTaskPolicy(policyId: string, description: string, rules: TaskPolicyRulesInput): PolicyDescriptor;
163
- export declare function buildHandoffPolicy(policyId: string, description: string, rules: HandoffPolicyRulesInput): PolicyDescriptor;
193
+ export declare function buildQuorumPolicy(policyId: string, description: string, rules: QuorumPolicyRulesInput, options?: PolicyRuleExtras): PolicyDescriptor;
194
+ /**
195
+ * The approval bar a Quorum session actually needs, or `null` when it is
196
+ * unsatisfiable.
197
+ *
198
+ * Since runtime 0.9.0 (S4, RFC-MACP-0011 §5 rule 6) a bound policy's
199
+ * `threshold` replaces the `ApprovalRequest.requiredApprovals` payload value
200
+ * *as given*, even above the participant count, in which case the threshold
201
+ * is unreachable and a negative Commitment is eligible at once. Pass the
202
+ * result as `{ effectiveThreshold }` to the `QuorumProjection` readiness
203
+ * helpers (`null` included — they treat it as unreachable).
204
+ *
205
+ * A port of the runtime's `QuorumThreshold::effective` and
206
+ * `QuorumMode::effective_threshold`, with the same results as
207
+ * macp-sdk-python's `effective_quorum_threshold`:
208
+ *
209
+ * - no policy, no `threshold`, `value <= 0`/NaN, a non-numeric or boolean
210
+ * `value`, a non-string `type`, or unparsable rules (the runtime's
211
+ * `unwrap_or_default` path): `requiredApprovals`, unchanged;
212
+ * - `n_of_m` (the default `type`, and the runtime's `count` alias):
213
+ * `max(1, ceil(value))`;
214
+ * - integer `percentage`: `max(1, ceil(value * n / 100))` in exact integer
215
+ * arithmetic (RFC-MACP-0012 §4.2 forbids float division); fractional
216
+ * `percentage`: `max(1, ceil(value * n / 100))`;
217
+ * - `percentage` with no participants, or an unknown `type`: `null`;
218
+ * - every result saturates at `2^32 - 1` (an infinite value included).
219
+ *
220
+ * `rules` may be a `PolicyDescriptor`, its JSON `rules` string/bytes, or the
221
+ * decoded object.
222
+ */
223
+ export declare function effectiveQuorumThreshold(rules: PolicyDescriptor | string | Uint8Array | Record<string, unknown> | null | undefined, participantCount: number, requiredApprovals: number): number | null;
224
+ export declare function buildProposalPolicy(policyId: string, description: string, rules: ProposalPolicyRulesInput, options?: PolicyRuleExtras): PolicyDescriptor;
225
+ export declare function buildTaskPolicy(policyId: string, description: string, rules: TaskPolicyRulesInput, options?: PolicyRuleExtras): PolicyDescriptor;
226
+ export declare function buildHandoffPolicy(policyId: string, description: string, rules: HandoffPolicyRulesInput, options?: PolicyRuleExtras): PolicyDescriptor;
package/dist/policy.js CHANGED
@@ -1,13 +1,111 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CRITICAL_OBJECTION_ACTIONS = exports.PROPOSAL_ACCEPTANCE_CRITERIA = exports.ABSTENTION_INTERPRETATIONS = exports.COMMITMENT_AUTHORITIES = void 0;
4
+ exports.policyRuleExtensions = policyRuleExtensions;
3
5
  exports.buildDecisionPolicy = buildDecisionPolicy;
4
6
  exports.buildQuorumPolicy = buildQuorumPolicy;
7
+ exports.effectiveQuorumThreshold = effectiveQuorumThreshold;
5
8
  exports.buildProposalPolicy = buildProposalPolicy;
6
9
  exports.buildTaskPolicy = buildTaskPolicy;
7
10
  exports.buildHandoffPolicy = buildHandoffPolicy;
8
11
  const errors_1 = require("./errors");
12
+ // ── Rule value domains ──────────────────────────────────────────────
13
+ // Runtime 0.9.0 validates every rule object against the canonical
14
+ // spec `schemas/json/policy/*-rules.schema.json` and refuses an off-domain
15
+ // value at RegisterPolicy with INVALID_POLICY_DEFINITION; the builders refuse
16
+ // it client-side first. Same sets as macp-sdk-python's `macp_sdk.policy`;
17
+ // tests/unit/policy-schema-parity.test.ts pins them against the schemas.
18
+ // Typed against the rule-input unions so a widened union without a matching
19
+ // set entry is a tsc error.
20
+ /** `/properties/commitment/properties/authority` (all five modes). */
21
+ exports.COMMITMENT_AUTHORITIES = new Set([
22
+ 'initiator_only',
23
+ 'any_participant',
24
+ 'designated_role',
25
+ ]);
26
+ /** quorum-rules `/properties/abstention/properties/interpretation`. */
27
+ exports.ABSTENTION_INTERPRETATIONS = new Set([
28
+ 'neutral',
29
+ 'implicit_reject',
30
+ 'ignored',
31
+ ]);
32
+ /** proposal-rules `/properties/acceptance/properties/criterion`. */
33
+ exports.PROPOSAL_ACCEPTANCE_CRITERIA = new Set([
34
+ 'all_parties',
35
+ 'counterparty',
36
+ 'initiator',
37
+ ]);
38
+ /** decision-rules `/properties/objection_handling/properties/critical_objection_action`. */
39
+ exports.CRITICAL_OBJECTION_ACTIONS = new Set(['deny', 'finalize_decline', 'hold']);
40
+ // decision-rules `/properties/voting/properties/quorum/properties/type`.
41
+ const VOTING_QUORUM_TYPES = new Set(['count', 'percentage']);
42
+ // RFC-MACP-0012 §4 key classes legal at the rules top level besides the
43
+ // standard sections: `x-` vendor extensions (object-valued) and `_`/`$`
44
+ // annotations (any value). Same patterns as the schemas' patternProperties.
45
+ const EXTENSION_KEY_RE = /^x-[a-z0-9]+(\.[a-z0-9-]+)+$/;
46
+ const ANNOTATION_KEY_RE = /^[_$]/;
47
+ // An own enumerable key even for `__proto__` (a legal annotation name),
48
+ // which plain assignment would turn into a prototype swap that
49
+ // JSON.stringify then drops.
50
+ function defineKey(rules, key, value) {
51
+ Object.defineProperty(rules, key, { value, enumerable: true, writable: true, configurable: true });
52
+ }
53
+ function addExtraKeys(rules, extras) {
54
+ for (const [key, value] of Object.entries(extras?.extensions ?? {})) {
55
+ if (Object.hasOwn(rules, key)) {
56
+ throw new errors_1.MacpSessionError(`extension key '${key}' collides with a standard rule section`);
57
+ }
58
+ if (!EXTENSION_KEY_RE.test(key)) {
59
+ throw new errors_1.MacpSessionError(`extension key '${key}' must match ${EXTENSION_KEY_RE.source} (e.g. 'x-com.acme.audit')`);
60
+ }
61
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
62
+ throw new errors_1.MacpSessionError(`extension '${key}' must be an object, got ${JSON.stringify(value)}`);
63
+ }
64
+ defineKey(rules, key, { ...value });
65
+ }
66
+ for (const [name, note] of Object.entries(extras?.annotations ?? {})) {
67
+ if (Object.hasOwn(rules, name)) {
68
+ throw new errors_1.MacpSessionError(`annotation key '${name}' collides with a standard rule section`);
69
+ }
70
+ if (!ANNOTATION_KEY_RE.test(name)) {
71
+ throw new errors_1.MacpSessionError(`annotation key '${name}' must start with '_' or '$'`);
72
+ }
73
+ defineKey(rules, name, note);
74
+ }
75
+ }
76
+ /**
77
+ * The `x-` rule-extension keys a runtime advertises
78
+ * (`capabilities.policyRegistry.ruleExtensions`), or `[]` when it advertises
79
+ * none, including runtimes older than 0.9.0. Advisory: a descriptor carrying
80
+ * an unlisted key is refused at RegisterPolicy, but admission is what
81
+ * decides. Accepts the `initialize()` result or its `capabilities`.
82
+ */
83
+ function policyRuleExtensions(source) {
84
+ const capabilities = source && 'selectedProtocolVersion' in source
85
+ ? source.capabilities
86
+ : (source ?? undefined);
87
+ const registry = capabilities?.policyRegistry;
88
+ const keys = registry?.ruleExtensions;
89
+ return Array.isArray(keys) ? keys.filter((k) => typeof k === 'string') : [];
90
+ }
91
+ function checkEnum(fieldName, value, allowed) {
92
+ if (typeof value !== 'string' || !allowed.has(value)) {
93
+ throw new errors_1.MacpSessionError(`${fieldName} must be one of ${[...allowed].sort().join(', ')}, got ${JSON.stringify(value)}`);
94
+ }
95
+ }
96
+ // Number.isInteger rejects booleans, NaN and ±Infinity, so unlike Python's
97
+ // `_check_min_int` no separate bool guard is needed.
98
+ function checkMinInt(fieldName, value, minimum) {
99
+ if (!Number.isInteger(value) || value < minimum) {
100
+ throw new errors_1.MacpSessionError(`${fieldName} must be an integer >= ${minimum}, got ${JSON.stringify(value)}`);
101
+ }
102
+ }
103
+ function isFiniteNumber(value) {
104
+ return typeof value === 'number' && Number.isFinite(value);
105
+ }
9
106
  // ── Builder helpers ─────────────────────────────────────────────────
10
107
  function serializeCommitment(commitment) {
108
+ checkEnum('commitment.authority', commitment?.authority ?? 'initiator_only', exports.COMMITMENT_AUTHORITIES);
11
109
  // `authority: 'designated_role'` with an empty (or unset) `designatedRoles`
12
110
  // names no one, so no sender could ever satisfy it. All five rule schemas
13
111
  // reject this combination at admission (spec PR #121) via a root-level
@@ -117,6 +215,21 @@ function buildDecisionPolicy(policyId, description, rules, options) {
117
215
  }
118
216
  }
119
217
  }
218
+ // Value domains the runtime enforces since 0.9.0 (decision-rules.schema.json).
219
+ // Same order as macp-sdk-python's build_decision_policy.
220
+ const quorum = rules.voting?.quorum;
221
+ if (quorum !== undefined) {
222
+ checkEnum('voting.quorum_type', quorum.type, VOTING_QUORUM_TYPES);
223
+ if (!isFiniteNumber(quorum.value) || quorum.value < 0) {
224
+ throw new errors_1.MacpSessionError(`voting.quorum_value must be a finite number >= 0, got ${JSON.stringify(quorum.value)}`);
225
+ }
226
+ }
227
+ checkMinInt('objection_handling.veto_threshold', rules.objectionHandling?.vetoThreshold ?? 1, 1);
228
+ checkEnum('objection_handling.critical_objection_action', rules.objectionHandling?.criticalObjectionAction ?? 'deny', exports.CRITICAL_OBJECTION_ACTIONS);
229
+ const minimumConfidence = rules.evaluation?.minimumConfidence ?? 0;
230
+ if (!isFiniteNumber(minimumConfidence) || minimumConfidence < 0 || minimumConfidence > 1) {
231
+ throw new errors_1.MacpSessionError(`evaluation.minimum_confidence must be in [0.0, 1.0], got ${JSON.stringify(minimumConfidence)}`);
232
+ }
120
233
  // Decision-only: extend the shared commitment rules with the two keys
121
234
  // decision-rules.schema.json's commitment object allows and the other four
122
235
  // modes' commitment schemas do not -- `require_vote_quorum` and the
@@ -144,6 +257,7 @@ function buildDecisionPolicy(policyId, description, rules, options) {
144
257
  },
145
258
  commitment: commitmentSection,
146
259
  };
260
+ addExtraKeys(rulesJson, options);
147
261
  return {
148
262
  policyId,
149
263
  mode: 'macp.mode.decision.v1',
@@ -152,7 +266,7 @@ function buildDecisionPolicy(policyId, description, rules, options) {
152
266
  schemaVersion,
153
267
  };
154
268
  }
155
- function buildQuorumPolicy(policyId, description, rules) {
269
+ function buildQuorumPolicy(policyId, description, rules, options) {
156
270
  const threshold = rules.threshold;
157
271
  if (threshold) {
158
272
  // Match the canonical quorum-rules schema's constraints before the runtime
@@ -186,6 +300,7 @@ function buildQuorumPolicy(policyId, description, rules) {
186
300
  'defined semantics and must not be used.');
187
301
  }
188
302
  }
303
+ checkEnum('abstention.interpretation', rules.abstention?.interpretation ?? 'neutral', exports.ABSTENTION_INTERPRETATIONS);
189
304
  const rulesJson = {
190
305
  threshold: {
191
306
  type: rules.threshold?.type ?? 'n_of_m',
@@ -197,6 +312,7 @@ function buildQuorumPolicy(policyId, description, rules) {
197
312
  },
198
313
  commitment: serializeCommitment(rules.commitment),
199
314
  };
315
+ addExtraKeys(rulesJson, options);
200
316
  return {
201
317
  policyId,
202
318
  mode: 'macp.mode.quorum.v1',
@@ -205,7 +321,92 @@ function buildQuorumPolicy(policyId, description, rules) {
205
321
  schemaVersion: 1,
206
322
  };
207
323
  }
208
- function buildProposalPolicy(policyId, description, rules) {
324
+ const U32_MAX = 2 ** 32 - 1;
325
+ /**
326
+ * The approval bar a Quorum session actually needs, or `null` when it is
327
+ * unsatisfiable.
328
+ *
329
+ * Since runtime 0.9.0 (S4, RFC-MACP-0011 §5 rule 6) a bound policy's
330
+ * `threshold` replaces the `ApprovalRequest.requiredApprovals` payload value
331
+ * *as given*, even above the participant count, in which case the threshold
332
+ * is unreachable and a negative Commitment is eligible at once. Pass the
333
+ * result as `{ effectiveThreshold }` to the `QuorumProjection` readiness
334
+ * helpers (`null` included — they treat it as unreachable).
335
+ *
336
+ * A port of the runtime's `QuorumThreshold::effective` and
337
+ * `QuorumMode::effective_threshold`, with the same results as
338
+ * macp-sdk-python's `effective_quorum_threshold`:
339
+ *
340
+ * - no policy, no `threshold`, `value <= 0`/NaN, a non-numeric or boolean
341
+ * `value`, a non-string `type`, or unparsable rules (the runtime's
342
+ * `unwrap_or_default` path): `requiredApprovals`, unchanged;
343
+ * - `n_of_m` (the default `type`, and the runtime's `count` alias):
344
+ * `max(1, ceil(value))`;
345
+ * - integer `percentage`: `max(1, ceil(value * n / 100))` in exact integer
346
+ * arithmetic (RFC-MACP-0012 §4.2 forbids float division); fractional
347
+ * `percentage`: `max(1, ceil(value * n / 100))`;
348
+ * - `percentage` with no participants, or an unknown `type`: `null`;
349
+ * - every result saturates at `2^32 - 1` (an infinite value included).
350
+ *
351
+ * `rules` may be a `PolicyDescriptor`, its JSON `rules` string/bytes, or the
352
+ * decoded object.
353
+ */
354
+ function effectiveQuorumThreshold(rules, participantCount, requiredApprovals) {
355
+ let parsed = rules;
356
+ if (isPolicyDescriptor(parsed))
357
+ parsed = parsed.rules;
358
+ if (typeof parsed === 'string' || parsed instanceof Uint8Array) {
359
+ const text = typeof parsed === 'string' ? parsed : Buffer.from(parsed).toString('utf8');
360
+ if (!text)
361
+ return requiredApprovals;
362
+ try {
363
+ parsed = JSON.parse(text);
364
+ }
365
+ catch {
366
+ return requiredApprovals;
367
+ }
368
+ }
369
+ if (!isPlainRecord(parsed))
370
+ return requiredApprovals;
371
+ const threshold = parsed.threshold;
372
+ if (!isPlainRecord(threshold))
373
+ return requiredApprovals;
374
+ const type = threshold.type === undefined ? 'n_of_m' : threshold.type;
375
+ const value = threshold.value === undefined ? 0 : threshold.value;
376
+ if (typeof type !== 'string' || typeof value !== 'number')
377
+ return requiredApprovals;
378
+ // NaN, 0 and negatives are all inert (the runtime's partial_cmp guard).
379
+ if (!(value > 0))
380
+ return requiredApprovals;
381
+ let raw;
382
+ if (type === 'percentage') {
383
+ if (participantCount === 0)
384
+ return null;
385
+ if (Number.isInteger(value)) {
386
+ const required = (BigInt(value) * BigInt(participantCount) + 99n) / 100n;
387
+ return Number(required > BigInt(U32_MAX) ? BigInt(U32_MAX) : required < 1n ? 1n : required);
388
+ }
389
+ raw = (value * participantCount) / 100;
390
+ }
391
+ else if (type === 'n_of_m' || type === 'count') {
392
+ raw = value;
393
+ }
394
+ else {
395
+ return null;
396
+ }
397
+ if (raw === Infinity)
398
+ return U32_MAX;
399
+ return Math.min(Math.max(1, Math.ceil(raw)), U32_MAX);
400
+ }
401
+ function isPlainRecord(value) {
402
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array);
403
+ }
404
+ function isPolicyDescriptor(value) {
405
+ return isPlainRecord(value) && typeof value.policyId === 'string' && typeof value.rules === 'string';
406
+ }
407
+ function buildProposalPolicy(policyId, description, rules, options) {
408
+ checkEnum('acceptance.criterion', rules.acceptance?.criterion ?? 'all_parties', exports.PROPOSAL_ACCEPTANCE_CRITERIA);
409
+ checkMinInt('counter_proposal.max_rounds', rules.counterProposal?.maxRounds ?? 0, 0);
209
410
  const rulesJson = {
210
411
  acceptance: {
211
412
  criterion: rules.acceptance?.criterion ?? 'all_parties',
@@ -218,6 +419,7 @@ function buildProposalPolicy(policyId, description, rules) {
218
419
  },
219
420
  commitment: serializeCommitment(rules.commitment),
220
421
  };
422
+ addExtraKeys(rulesJson, options);
221
423
  return {
222
424
  policyId,
223
425
  mode: 'macp.mode.proposal.v1',
@@ -226,7 +428,7 @@ function buildProposalPolicy(policyId, description, rules) {
226
428
  schemaVersion: 1,
227
429
  };
228
430
  }
229
- function buildTaskPolicy(policyId, description, rules) {
431
+ function buildTaskPolicy(policyId, description, rules, options) {
230
432
  const rulesJson = {
231
433
  assignment: {
232
434
  allow_reassignment_on_reject: rules.assignment?.allowReassignmentOnReject ?? false,
@@ -236,6 +438,7 @@ function buildTaskPolicy(policyId, description, rules) {
236
438
  },
237
439
  commitment: serializeCommitment(rules.commitment),
238
440
  };
441
+ addExtraKeys(rulesJson, options);
239
442
  return {
240
443
  policyId,
241
444
  mode: 'macp.mode.task.v1',
@@ -244,13 +447,15 @@ function buildTaskPolicy(policyId, description, rules) {
244
447
  schemaVersion: 1,
245
448
  };
246
449
  }
247
- function buildHandoffPolicy(policyId, description, rules) {
450
+ function buildHandoffPolicy(policyId, description, rules, options) {
451
+ checkMinInt('acceptance.implicit_accept_timeout_ms', rules.acceptance?.implicitAcceptTimeoutMs ?? 0, 0);
248
452
  const rulesJson = {
249
453
  acceptance: {
250
454
  implicit_accept_timeout_ms: rules.acceptance?.implicitAcceptTimeoutMs ?? 0,
251
455
  },
252
456
  commitment: serializeCommitment(rules.commitment),
253
457
  };
458
+ addExtraKeys(rulesJson, options);
254
459
  return {
255
460
  policyId,
256
461
  mode: 'macp.mode.handoff.v1',
@@ -36,4 +36,17 @@ export declare class HandoffProjection extends BaseProjection {
36
36
  pendingHandoffs(): HandoffRecord[];
37
37
  hasAcceptedOffer(handoffId?: string): boolean;
38
38
  activeOffer(): HandoffRecord | undefined;
39
+ /**
40
+ * Targets that declined an offer this session, in offer order, de-duplicated.
41
+ * Keyed on the offer's `targetParticipant` (as the runtime is), not on the
42
+ * payload-supplied `declinedBy`, which may be empty.
43
+ */
44
+ declinedTargets(): string[];
45
+ /**
46
+ * True if a new `HandoffOffer` to `target` would be admissible: `target` has
47
+ * not declined earlier in the session (rule 3a), no offer is outstanding,
48
+ * and no offer has been accepted. Does not check that `target` is a declared
49
+ * participant other than the sender.
50
+ */
51
+ canOfferTo(target: string): boolean;
39
52
  }
@@ -162,5 +162,32 @@ class HandoffProjection extends base_1.BaseProjection {
162
162
  }
163
163
  return undefined;
164
164
  }
165
+ // Advisory (RFC-MACP-0010 §5 rule 3a, runtime ≥ 0.9.0): modelled from what
166
+ // this projection has seen. A projection that joined mid-session may miss
167
+ // earlier offers, and sessions started on a runtime < 0.9.0 do not enforce
168
+ // rule 3a. Nothing refuses client-side — `HandoffSession.offer()` still
169
+ // sends, and the runtime's `INVALID_ENVELOPE` NACK is authoritative. Same
170
+ // names and semantics as macp-sdk-python's `HandoffProjection`.
171
+ /**
172
+ * Targets that declined an offer this session, in offer order, de-duplicated.
173
+ * Keyed on the offer's `targetParticipant` (as the runtime is), not on the
174
+ * payload-supplied `declinedBy`, which may be empty.
175
+ */
176
+ declinedTargets() {
177
+ return [
178
+ ...new Set([...this.handoffs.values()].filter((h) => h.status === 'declined').map((h) => h.targetParticipant)),
179
+ ];
180
+ }
181
+ /**
182
+ * True if a new `HandoffOffer` to `target` would be admissible: `target` has
183
+ * not declined earlier in the session (rule 3a), no offer is outstanding,
184
+ * and no offer has been accepted. Does not check that `target` is a declared
185
+ * participant other than the sender.
186
+ */
187
+ canOfferTo(target) {
188
+ if (this.declinedTargets().includes(target))
189
+ return false;
190
+ return ![...this.handoffs.values()].some((h) => h.status === 'offered' || h.status === 'context_sent' || h.status === 'accepted');
191
+ }
165
192
  }
166
193
  exports.HandoffProjection = HandoffProjection;
@@ -14,6 +14,15 @@ export interface BallotRecord {
14
14
  reason?: string;
15
15
  sender: string;
16
16
  }
17
+ /**
18
+ * Optional policy override for the `QuorumProjection` readiness helpers.
19
+ * `effectiveThreshold` is `effectiveQuorumThreshold()`'s result: a number
20
+ * replaces the payload's `requiredApprovals`, `null` means unsatisfiable
21
+ * (treated as an infinite bar), and omitting it keeps the payload value.
22
+ */
23
+ export interface QuorumThresholdOptions {
24
+ effectiveThreshold?: number | null;
25
+ }
17
26
  export declare class QuorumProjection extends BaseProjection {
18
27
  protected readonly mode = "macp.mode.quorum.v1";
19
28
  readonly requests: Map<string, ApprovalRequestRecord>;
@@ -25,11 +34,29 @@ export declare class QuorumProjection extends BaseProjection {
25
34
  approvalCount(requestId: string): number;
26
35
  rejectionCount(requestId: string): number;
27
36
  abstentionCount(requestId: string): number;
28
- hasQuorum(requestId: string): boolean;
29
- threshold(requestId: string): number;
37
+ /**
38
+ * The approval bar for `requestId`: the policy override when one is passed,
39
+ * else the payload's `requiredApprovals`. `null` (an unsatisfiable override,
40
+ * see `effectiveQuorumThreshold`) is an infinite bar. `undefined` for an
41
+ * unknown request.
42
+ */
43
+ private required;
44
+ /**
45
+ * True once the approval count reaches the bar. Pass `{ effectiveThreshold }`
46
+ * (from `effectiveQuorumThreshold`) when a bound policy's `threshold`
47
+ * overrides `requiredApprovals` (runtime ≥ 0.9.0, RFC-MACP-0011 §5 rule 6).
48
+ */
49
+ hasQuorum(requestId: string, options?: QuorumThresholdOptions): boolean;
50
+ /** The approval bar, or 0 if the request is unknown. `Infinity` for an unsatisfiable override. */
51
+ threshold(requestId: string, options?: QuorumThresholdOptions): number;
30
52
  votedSenders(requestId: string): string[];
31
- remainingVotesNeeded(requestId: string): number;
32
- commitmentReady(requestId: string): boolean;
33
- isThresholdUnreachable(requestId: string, totalEligible: number): boolean;
53
+ remainingVotesNeeded(requestId: string, options?: QuorumThresholdOptions): number;
54
+ commitmentReady(requestId: string, options?: QuorumThresholdOptions): boolean;
55
+ /**
56
+ * True if the remaining possible approvals cannot reach the bar. Under a
57
+ * policy `threshold` override above the participant count (runtime ≥ 0.9.0)
58
+ * this is true before any ballot — pass `{ effectiveThreshold }` to see it.
59
+ */
60
+ isThresholdUnreachable(requestId: string, totalEligible: number, options?: QuorumThresholdOptions): boolean;
34
61
  private countVotes;
35
62
  }
@@ -95,34 +95,60 @@ class QuorumProjection extends base_1.BaseProjection {
95
95
  abstentionCount(requestId) {
96
96
  return this.countVotes(requestId, 'abstain');
97
97
  }
98
- hasQuorum(requestId) {
98
+ /**
99
+ * The approval bar for `requestId`: the policy override when one is passed,
100
+ * else the payload's `requiredApprovals`. `null` (an unsatisfiable override,
101
+ * see `effectiveQuorumThreshold`) is an infinite bar. `undefined` for an
102
+ * unknown request.
103
+ */
104
+ required(requestId, options) {
99
105
  const req = this.requests.get(requestId);
100
106
  if (!req)
107
+ return undefined;
108
+ const override = options?.effectiveThreshold;
109
+ if (override === undefined)
110
+ return req.requiredApprovals;
111
+ return override === null ? Infinity : override;
112
+ }
113
+ /**
114
+ * True once the approval count reaches the bar. Pass `{ effectiveThreshold }`
115
+ * (from `effectiveQuorumThreshold`) when a bound policy's `threshold`
116
+ * overrides `requiredApprovals` (runtime ≥ 0.9.0, RFC-MACP-0011 §5 rule 6).
117
+ */
118
+ hasQuorum(requestId, options) {
119
+ const required = this.required(requestId, options);
120
+ if (required === undefined)
101
121
  return false;
102
- return this.approvalCount(requestId) >= req.requiredApprovals;
122
+ return this.approvalCount(requestId) >= required;
103
123
  }
104
- threshold(requestId) {
105
- return this.requests.get(requestId)?.requiredApprovals ?? 0;
124
+ /** The approval bar, or 0 if the request is unknown. `Infinity` for an unsatisfiable override. */
125
+ threshold(requestId, options) {
126
+ return this.required(requestId, options) ?? 0;
106
127
  }
107
128
  votedSenders(requestId) {
108
129
  const senderMap = this.ballots.get(requestId);
109
130
  return senderMap ? [...senderMap.keys()] : [];
110
131
  }
111
- remainingVotesNeeded(requestId) {
112
- const req = this.requests.get(requestId);
113
- if (!req)
132
+ remainingVotesNeeded(requestId, options) {
133
+ const required = this.required(requestId, options);
134
+ if (required === undefined)
114
135
  return 0;
115
- return Math.max(0, req.requiredApprovals - this.approvalCount(requestId));
136
+ return Math.max(0, required - this.approvalCount(requestId));
116
137
  }
117
- commitmentReady(requestId) {
118
- return this.hasQuorum(requestId) && this.phase !== 'Committed';
138
+ commitmentReady(requestId, options) {
139
+ return this.hasQuorum(requestId, options) && this.phase !== 'Committed';
119
140
  }
120
- isThresholdUnreachable(requestId, totalEligible) {
121
- const req = this.requests.get(requestId);
122
- if (!req)
141
+ /**
142
+ * True if the remaining possible approvals cannot reach the bar. Under a
143
+ * policy `threshold` override above the participant count (runtime ≥ 0.9.0)
144
+ * this is true before any ballot — pass `{ effectiveThreshold }` to see it.
145
+ */
146
+ isThresholdUnreachable(requestId, totalEligible, options) {
147
+ const required = this.required(requestId, options);
148
+ if (required === undefined)
123
149
  return false;
124
150
  const remaining = totalEligible - this.votedSenders(requestId).length;
125
- return this.approvalCount(requestId) + remaining < req.requiredApprovals;
151
+ return this.approvalCount(requestId) + remaining < required;
126
152
  }
127
153
  countVotes(requestId, vote) {
128
154
  const senderMap = this.ballots.get(requestId);
@@ -38,6 +38,13 @@ export interface TaskRejectRecord {
38
38
  assignee: string;
39
39
  reason?: string;
40
40
  sender: string;
41
+ /**
42
+ * `true` when the rejecter held the session's active assignment (RFC-MACP-0009
43
+ * §5 rule 3c: the assignee gave the task back). `false` for a pre-assignment
44
+ * personal decline (rule 2a, runtime ≥ 0.9.0), which leaves the task open to
45
+ * the other participants. Mirrors the runtime's `TaskRejectRecord.while_assigned`.
46
+ */
47
+ whileAssigned: boolean;
41
48
  }
42
49
  export declare class TaskProjection extends BaseProjection {
43
50
  protected readonly mode = "macp.mode.task.v1";
@@ -77,4 +84,22 @@ export declare class TaskProjection extends BaseProjection {
77
84
  activeTasks(): TaskRecord[];
78
85
  isAccepted(taskId: string): boolean;
79
86
  latestProgress(): number | undefined;
87
+ /** True if `participant` has sent a `TaskReject` (either kind). */
88
+ hasDeclined(participant: string): boolean;
89
+ /** Participants that sent a `TaskReject`, in arrival order, de-duplicated. */
90
+ declinedParticipants(): string[];
91
+ /**
92
+ * True if `participant` may still send `TaskAccept`: the session's assignee
93
+ * slot is free and `participant` has not declined (rule 2a). Does not
94
+ * evaluate the bound policy's `allow_reassignment_on_reject`, which the
95
+ * runtime also requires after an active assignee gave the task back.
96
+ */
97
+ canAccept(participant: string): boolean;
98
+ /**
99
+ * True if a negative Commitment is eligible for `taskId`: a terminal report
100
+ * (`TaskComplete`/`TaskFail`) exists, or the named `requestedAssignee`
101
+ * declined before accepting and no one holds the slot (rule 2a), leaving no
102
+ * eligible assignee.
103
+ */
104
+ negativeCommitEligible(taskId: string): boolean;
80
105
  }
@@ -119,9 +119,17 @@ class TaskProjection extends base_1.BaseProjection {
119
119
  // `p.assignee or envelope.sender` does: TaskComplete/TaskFail below
120
120
  // spread the payload's `assignee` as-is with no such fallback, and
121
121
  // intra-SDK consistency wins over replicating Python's nuance here.
122
- this.rejections.push({ ...record, sender: envelope.sender });
122
+ //
123
+ // `whileAssigned` is computed before the slot is freed (runtime
124
+ // `task.rs`, same order). Only the active assignee's reject (rule 3c)
125
+ // marks the task 'rejected'; a pre-assignment reject is a personal
126
+ // decline (RFC-MACP-0009 §5 rule 2a, runtime ≥ 0.9.0) that leaves the
127
+ // task 'requested' for everyone else.
128
+ const slot = this.activeAssignment;
129
+ const whileAssigned = slot?.sender === envelope.sender;
130
+ this.rejections.push({ ...record, sender: envelope.sender, whileAssigned });
123
131
  const task = this.tasks.get(record.taskId);
124
- if (task)
132
+ if (task && whileAssigned)
125
133
  task.status = 'rejected';
126
134
  // RFC-MACP-0009 §5 rule 3c (`:72`): "When policy sets
127
135
  // `allow_reassignment_on_reject: true` and the active assignee sends
@@ -144,8 +152,8 @@ class TaskProjection extends base_1.BaseProjection {
144
152
  // cannot diverge from the runtime on any real transcript, and it needs
145
153
  // no new API surface (threading a `PolicyDefinition` into the
146
154
  // constructor would). Issue #70, option 2.
147
- if (this.activeAssignment?.sender === envelope.sender) {
148
- const held = this.tasks.get(this.activeAssignment.taskId);
155
+ if (slot && whileAssigned) {
156
+ const held = this.tasks.get(slot.taskId);
149
157
  if (held)
150
158
  held.assignee = undefined;
151
159
  this.activeAssignment = undefined;
@@ -213,5 +221,56 @@ class TaskProjection extends base_1.BaseProjection {
213
221
  latestProgress() {
214
222
  return this.updates[this.updates.length - 1]?.progress;
215
223
  }
224
+ // Advisory: the helpers below model the runtime's rev-4 rules (RFC-MACP-0009
225
+ // §5 rule 2a, runtime ≥ 0.9.0) from what this projection has seen. A
226
+ // projection that joined mid-session may be partial, and sessions started on
227
+ // a runtime < 0.9.0 keep the earlier rule (any prior reject gates every later
228
+ // accept). None of them refuse anything: `TaskSession.acceptTask()` still
229
+ // sends, and the runtime's NACK stays authoritative. Same names and semantics
230
+ // as macp-sdk-python's `TaskProjection`.
231
+ /** True if `participant` has sent a `TaskReject` (either kind). */
232
+ hasDeclined(participant) {
233
+ return this.rejections.some((r) => rejecterOf(r) === participant);
234
+ }
235
+ /** Participants that sent a `TaskReject`, in arrival order, de-duplicated. */
236
+ declinedParticipants() {
237
+ return [...new Set(this.rejections.map(rejecterOf))];
238
+ }
239
+ /**
240
+ * True if `participant` may still send `TaskAccept`: the session's assignee
241
+ * slot is free and `participant` has not declined (rule 2a). Does not
242
+ * evaluate the bound policy's `allow_reassignment_on_reject`, which the
243
+ * runtime also requires after an active assignee gave the task back.
244
+ */
245
+ canAccept(participant) {
246
+ return this.activeAssignment === undefined && !this.hasDeclined(participant);
247
+ }
248
+ /**
249
+ * True if a negative Commitment is eligible for `taskId`: a terminal report
250
+ * (`TaskComplete`/`TaskFail`) exists, or the named `requestedAssignee`
251
+ * declined before accepting and no one holds the slot (rule 2a), leaving no
252
+ * eligible assignee.
253
+ */
254
+ negativeCommitEligible(taskId) {
255
+ const task = this.tasks.get(taskId);
256
+ if (!task)
257
+ return false;
258
+ if (this.completions.some((c) => c.taskId === taskId) || this.failures.some((f) => f.taskId === taskId)) {
259
+ return true;
260
+ }
261
+ const requested = task.requestedAssignee;
262
+ return (!!requested &&
263
+ this.activeAssignment === undefined &&
264
+ this.rejections.some((r) => rejecterOf(r) === requested && !r.whileAssigned));
265
+ }
216
266
  }
217
267
  exports.TaskProjection = TaskProjection;
268
+ /**
269
+ * Who a rejection is about: the payload's `assignee`, falling back to the
270
+ * envelope sender when the payload leaves it empty (Python's
271
+ * `p.assignee or envelope.sender`). Applied here, not to the stored record, so
272
+ * `TaskRejectRecord.assignee` keeps its as-sent value like TaskComplete/TaskFail.
273
+ */
274
+ function rejecterOf(r) {
275
+ return r.assignee || r.sender;
276
+ }
package/dist/types.d.ts CHANGED
@@ -86,6 +86,21 @@ export interface AgentManifest {
86
86
  metadata?: Record<string, string>;
87
87
  transportEndpoints?: TransportEndpoint[];
88
88
  }
89
+ /**
90
+ * `capabilities.policyRegistry` (`macp.v1.PolicyRegistryCapability`) as
91
+ * `InitializeResult.capabilities` carries it.
92
+ */
93
+ export interface PolicyRegistryCapability {
94
+ registerPolicy?: boolean;
95
+ listPolicies?: boolean;
96
+ listChanged?: boolean;
97
+ /**
98
+ * Vendor-extension keys (RFC-MACP-0012 §4) this runtime implements, by full
99
+ * key name. Any other `x-` key is refused at RegisterPolicy. Advisory:
100
+ * admission, not this list, is authoritative. Runtime ≥ 0.9.0.
101
+ */
102
+ ruleExtensions?: string[];
103
+ }
89
104
  export interface InitializeResult {
90
105
  selectedProtocolVersion: string;
91
106
  runtimeInfo?: {
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.16.1";
7
+ export declare const VERSION = "0.17.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.16.1'; // x-release-please-version
10
+ exports.VERSION = '0.17.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macp-sdk-typescript",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",