macp-sdk-typescript 0.9.0 → 0.11.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,113 @@ 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.11.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.10.0...v0.11.0) (2026-09-22)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * **policy:** buildDecisionPolicy's default schemaVersion is now 3 (was 2). A call that omits options.schemaVersion and uses a binding voting algorithm now fails closed on an empty decisive tally instead of silently sealing on zero ballots. Pass { schemaVersion: 1 } or { schemaVersion: 2 } explicitly to keep the old fail-open behavior.
13
+
14
+ ### Features
15
+
16
+ * **policy:** flip Decision-policy schemaVersion default from 2 to 3 ([#97](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/97)) ([a82972a](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/a82972ae43550f4b953a6bd0e32bfb387fd25806))
17
+
18
+ ## [Unreleased]
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * **policy:** `QuorumThreshold.type` no longer accepts `'weighted'`. The
23
+ identifier was removed from the canonical `quorum-rules.schema.json`
24
+ without ever having defined semantics (no weights vocabulary, no
25
+ electorate rule) and every descriptor that used it was already refused by
26
+ the runtime at `RegisterPolicy` — so this is a compile-time break for
27
+ callers whose descriptors could never register in the first place.
28
+ `buildQuorumPolicy` now also rejects it at runtime (for JS callers or an
29
+ `as` cast), naming the reservation.
30
+ * **policy:** `buildQuorumPolicy(id, desc, {})` (an omitted `threshold`) now
31
+ emits `threshold: { value: 1 }` instead of `value: 0`. A zero approval bar
32
+ is trivially satisfied by any ballot set, so the canonical schema declares
33
+ `value` with `exclusiveMinimum: 0`; `buildQuorumPolicy` now enforces that
34
+ bound for every `threshold.type`, not just `'percentage'`, and rejects a
35
+ non-integer or non-positive `value` with `MacpSessionError`.
36
+ * **policy:** `buildDecisionPolicy` now enforces the canonical
37
+ `decision-rules.schema.json` voting constraints client-side, so calls that
38
+ previously returned an unregistrable descriptor now throw `MacpSessionError`
39
+ instead:
40
+ - `voting.algorithm` must be one of the six canonical values.
41
+ - `voting.threshold` must be `0 < threshold <= 1`.
42
+ - `algorithm: 'majority'` requires `threshold >= 0.5` (inclusive — a `0.3`
43
+ "majority" now throws).
44
+ - `algorithm: 'supermajority'` **at its own default `threshold: 0.5`** now
45
+ throws — this is the change most likely to break an existing idiomatic
46
+ call (`buildDecisionPolicy(id, desc, { voting: { algorithm: 'supermajority' } })`);
47
+ pass an explicit `threshold` above `0.5` (e.g. `0.67`).
48
+ - `algorithm: 'weighted'` with no `weights` now throws.
49
+ - `voting.weights`, if supplied at all, is now validated **unconditionally
50
+ at every algorithm, not only `'weighted'`**: it must be non-empty, and
51
+ every value must be `> 0`. `weights: { a: 0 }` now throws — a weight-0
52
+ participant must be expressed by omission from the map.
53
+ * **policy:** all five `build*Policy` functions (`buildDecisionPolicy`,
54
+ `buildQuorumPolicy`, `buildProposalPolicy`, `buildTaskPolicy`,
55
+ `buildHandoffPolicy`) now throw `MacpSessionError` when
56
+ `commitment.authority` is `'designated_role'` and `designatedRoles` is
57
+ omitted or `[]`. An authority rule that names no one is unsatisfiable, and
58
+ every canonical rule schema already refused this combination at
59
+ `RegisterPolicy` — this call previously returned a descriptor the runtime
60
+ would reject.
61
+ * **policy:** `buildDecisionPolicy`'s default `schemaVersion` is now `3`
62
+ (was `2`), decided jointly with `macp-sdk-python`'s identical default flip
63
+ (issue #85, see `macp-sdk-python`#65). Any `buildDecisionPolicy` call that
64
+ omits `options.schemaVersion` and uses a binding voting algorithm
65
+ (`majority`, `unanimous`, etc.) now fails closed on an empty decisive
66
+ tally instead of silently sealing on zero ballots. Not retroactive — a
67
+ stored policy always evaluates under its own recorded `schemaVersion`
68
+ forever (RFC-MACP-0012 §8); this only changes what new callers who don't
69
+ pass `schemaVersion` explicitly get going forward. Pass
70
+ `{ schemaVersion: 1 }` or `{ schemaVersion: 2 }` explicitly to keep the
71
+ old fail-open behavior.
72
+
73
+ ### Features
74
+
75
+ * **policy:** `buildDecisionPolicy` accepts a new fourth `options` argument,
76
+ `{ schemaVersion?: 1 | 2 | 3 }`, to select a Decision policy's
77
+ RFC-MACP-0012 empty-tally semantics — `3` (the default) fails closed
78
+ (§4.1's "vacuous participation floor"); `1`/`2` fail open. An out-of-range
79
+ value throws `MacpSessionError`.
80
+
81
+ ### Bug Fixes
82
+
83
+ * **policy:** `buildQuorumPolicy`, `buildProposalPolicy`, `buildTaskPolicy`,
84
+ and `buildHandoffPolicy` no longer emit `require_vote_quorum` into their
85
+ `commitment` block. Only `decision-rules.schema.json`'s commitment object
86
+ declares that key; the other four canonical rule schemas close with
87
+ `additionalProperties: false` and do not, so the previous output was
88
+ spec-nonconformant with no CI signal (today's runtime does not enforce
89
+ `additionalProperties`, but a stricter or future validator would reject
90
+ it). Passing `commitment.requireVoteQuorum` to these four builders is now
91
+ silently dropped, matching its existing "ignored for other modes"
92
+ documentation, rather than leaking an unrecognized key onto the wire.
93
+ `buildDecisionPolicy` is unaffected.
94
+ * **projections:** all six `applyEnvelope` entry points now roll back
95
+ `transcript` and the `message_id` redelivery-dedup set on a failed payload
96
+ decode, instead of leaving a partial application in place. Previously, a
97
+ failed decode still marked the envelope's `message_id` "seen," so a
98
+ legitimate retry with a corrected payload was silently absorbed as a
99
+ redelivery and the envelope's effect was lost forever.
100
+
101
+ ## [0.10.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.9.0...v0.10.0) (2026-09-01)
102
+
103
+
104
+ ### ⚠ BREAKING CHANGES
105
+
106
+ * `client.sendProgress()` now throws `MacpSessionError` when exactly one of `sessionId`/`mode` is supplied. Such calls used to succeed against a runtime that had the symmetric gap; as of macp-runtime PR #137 they are rejected with `INVALID_ENVELOPE`. Pass both fields for a session-scoped Progress, or neither for an ambient one.
107
+ * **projections:** `TaskProjection` assignee semantics changed twice. (1) The first-accept-wins guard is now per-session, not per-`task_id`: on a transcript with two `TaskRequest`s, only the first `TaskAccept` assigns — the second task keeps `assignee === undefined` and `status === 'requested'`, where it previously got its own assignee. A `TaskAccept` the guard discards also no longer sets `phase = 'InProgress'`. (2) `TaskAccept` is no longer sticky forever: a `TaskReject` from the active assignee clears `assignee` back to `undefined`, so code reading `getTask(id).assignee` after a reject now sees `undefined` (or the reassigned participant) rather than the participant who rejected.
108
+
109
+ ### Bug Fixes
110
+
111
+ * **projections:** scope the Task assignee slot to the session and free it on reject ([#75](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/75)) ([f7841a9](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/f7841a96d120b3fc55effdfabf63a2d72f431081)), closes [#59](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/59) [#60](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/60) [#70](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/70) [#71](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/71)
112
+ * reject mixed ambient/session-scoped Progress envelopes ([#76](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/76)) ([a9e00e3](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/a9e00e370b7a4bf08d09190e8b75a12d221319f8))
113
+
7
114
  ## [0.9.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.8.0...v0.9.0) (2026-09-01)
8
115
 
9
116
 
package/README.md CHANGED
@@ -416,7 +416,7 @@ npm run test:integration # Integration tests (requires Docker runtime; not run
416
416
  ```
417
417
 
418
418
  CI (GitHub Actions) runs type-check, lint, format check, the coverage-gated
419
- test suite, and the build on Node 20, 22, and 24 for every push and pull
419
+ test suite, and the build on Node 22 and 24 for every push and pull
420
420
  request, and posts a coverage summary comment on PRs.
421
421
 
422
422
  ### Integration Tests
package/dist/client.d.ts CHANGED
@@ -262,6 +262,15 @@ export declare class MacpClient {
262
262
  auth?: AuthConfig;
263
263
  deadlineMs?: number;
264
264
  }): Promise<Ack>;
265
+ /**
266
+ * Send a `Progress` message.
267
+ *
268
+ * Per RFC-MACP-0001 §6, `Progress` is legal in exactly two shapes: *ambient*
269
+ * (`sessionId` and `mode` both omitted/empty) or *session-scoped* (both
270
+ * non-empty). Supplying exactly one of the two throws `MacpSessionError` —
271
+ * the runtime rejects that mixed envelope with `INVALID_ENVELOPE`, so this
272
+ * fails fast with a message naming the mismatched field.
273
+ */
265
274
  sendProgress(options: {
266
275
  sessionId?: string;
267
276
  mode?: string;
package/dist/client.js CHANGED
@@ -545,7 +545,17 @@ class MacpClient {
545
545
  });
546
546
  return this.send(envelope, { auth, deadlineMs: options.deadlineMs });
547
547
  }
548
+ /**
549
+ * Send a `Progress` message.
550
+ *
551
+ * Per RFC-MACP-0001 §6, `Progress` is legal in exactly two shapes: *ambient*
552
+ * (`sessionId` and `mode` both omitted/empty) or *session-scoped* (both
553
+ * non-empty). Supplying exactly one of the two throws `MacpSessionError` —
554
+ * the runtime rejects that mixed envelope with `INVALID_ENVELOPE`, so this
555
+ * fails fast with a message naming the mismatched field.
556
+ */
548
557
  async sendProgress(options) {
558
+ (0, validation_1.validateProgressScope)(options.sessionId ?? '', options.mode ?? '');
549
559
  const auth = this.requireAuth(options.auth);
550
560
  (0, auth_1.assertSenderMatchesIdentity)(auth, options.sender);
551
561
  const progressPayload = (0, envelope_1.buildProgressPayload)({
package/dist/policy.d.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  import type { PolicyDescriptor } from './types';
2
2
  export interface CommitmentRules {
3
3
  authority?: 'initiator_only' | 'any_participant' | 'designated_role';
4
+ /**
5
+ * Required non-empty when `authority` is `'designated_role'` — every
6
+ * builder throws `MacpSessionError` otherwise, since an authority rule
7
+ * naming no one is unsatisfiable. Ignored (serialized but inert) under the
8
+ * other two authorities.
9
+ */
4
10
  designatedRoles?: string[];
5
- /** Decision-specific: require quorum before commit. Ignored for other modes. */
11
+ /**
12
+ * Decision-specific: require quorum before commit. Emitted only by
13
+ * `buildDecisionPolicy` (matching `decision-rules.schema.json`'s
14
+ * commitment object, the only one of the five that declares this key) —
15
+ * silently dropped, not merely ignored, when passed to the other four
16
+ * builders, so the still-`additionalProperties: false`
17
+ * quorum/proposal/task/handoff commitment schemas are unaffected (issue
18
+ * #87 item 1).
19
+ */
6
20
  requireVoteQuorum?: boolean;
7
21
  /**
8
22
  * RFC-MACP-0012 schema_version 2, Decision mode only: when `true`, a
@@ -49,15 +63,20 @@ export interface EvaluationRules {
49
63
  requiredBeforeVoting?: boolean;
50
64
  }
51
65
  export interface QuorumThreshold {
52
- type: 'n_of_m' | 'percentage' | 'weighted';
66
+ type: 'n_of_m' | 'percentage';
53
67
  /**
54
68
  * The approval bar (RFC-MACP-0012 §4.2) — how many APPROVE commitments the
55
69
  * request needs, NOT a participation quorum. Scale depends on `type`:
56
- * - `n_of_m` / `weighted`: an absolute count (or weight sum).
57
- * - `percentage`: an **integer 0–100**; the runtime computes the bar as
70
+ * - `n_of_m`: an absolute count.
71
+ * - `percentage`: an **integer 1–100**; the runtime computes the bar as
58
72
  * `ceil(value / 100 × participants)`. So `75` means "≥ 75% of participants
59
- * must approve", NOT `0.75`. `buildQuorumPolicy` validates this range and
60
- * throws on a non-integer or out-of-[0,100] value.
73
+ * must approve", NOT `0.75`.
74
+ *
75
+ * Must be a **positive integer** for every `type` (`exclusiveMinimum: 0` in
76
+ * the canonical `quorum-rules.schema.json`, unconditional — a zero approval
77
+ * bar would be trivially satisfied by any ballot set). `buildQuorumPolicy`
78
+ * validates this and throws on a non-integer, non-positive, or (for
79
+ * `percentage`) out-of-[1,100] value.
61
80
  */
62
81
  value: number;
63
82
  }
@@ -109,7 +128,35 @@ export interface HandoffPolicyRulesInput {
109
128
  acceptance?: HandoffAcceptanceRules;
110
129
  commitment?: CommitmentRules;
111
130
  }
112
- export declare function buildDecisionPolicy(policyId: string, description: string, rules: DecisionPolicyRulesInput): PolicyDescriptor;
131
+ export interface DecisionPolicyOptions {
132
+ /**
133
+ * RFC-MACP-0012 rule schema version the runtime evaluates this policy
134
+ * under (validated, never re-validated, at admission -- RFC-MACP-0012 §8
135
+ * item 4):
136
+ * - `1`/`2`: an empty decisive tally is fail-*open* (satisfies the voting
137
+ * requirement).
138
+ * - `3`: an empty decisive tally is fail-*closed* for every algorithm
139
+ * except `'none'` (RFC-MACP-0012 §4.1, adopted in spec PR #99).
140
+ *
141
+ * Defaults to `3` (issue #85, decided jointly with `macp-sdk-python`'s
142
+ * identical `schema_version: int = 3` default -- see that repo's issue
143
+ * #65): RFC-MACP-0012's authoring guidance is that new non-`'none'`
144
+ * policies SHOULD declare `schemaVersion: 3`, and the old default of `2`
145
+ * was fail-*open* on an empty tally for exactly the callers who opted into
146
+ * a binding algorithm (`majority`, `unanimous`, etc.) -- silently
147
+ * satisfying a vote that received zero ballots, which contradicts what
148
+ * asking for a binding algorithm means. RFC-MACP-0012 §8 makes the choice
149
+ * reversible and non-retroactive: a stored policy always evaluates under
150
+ * its own declared version forever, so this default change carries no
151
+ * migration risk for existing registered policies -- it only changes what
152
+ * new callers who don't pass `schemaVersion` explicitly get going forward.
153
+ * Pass `1` or `2` explicitly to keep fail-open empty-tally semantics. This
154
+ * is descriptor metadata, not a rule: it is never part of the serialized
155
+ * `rules` JSON.
156
+ */
157
+ schemaVersion?: 1 | 2 | 3;
158
+ }
159
+ export declare function buildDecisionPolicy(policyId: string, description: string, rules: DecisionPolicyRulesInput, options?: DecisionPolicyOptions): PolicyDescriptor;
113
160
  export declare function buildQuorumPolicy(policyId: string, description: string, rules: QuorumPolicyRulesInput): PolicyDescriptor;
114
161
  export declare function buildProposalPolicy(policyId: string, description: string, rules: ProposalPolicyRulesInput): PolicyDescriptor;
115
162
  export declare function buildTaskPolicy(policyId: string, description: string, rules: TaskPolicyRulesInput): PolicyDescriptor;
package/dist/policy.js CHANGED
@@ -8,27 +8,126 @@ exports.buildHandoffPolicy = buildHandoffPolicy;
8
8
  const errors_1 = require("./errors");
9
9
  // ── Builder helpers ─────────────────────────────────────────────────
10
10
  function serializeCommitment(commitment) {
11
+ // `authority: 'designated_role'` with an empty (or unset) `designatedRoles`
12
+ // names no one, so no sender could ever satisfy it. All five rule schemas
13
+ // reject this combination at admission (spec PR #121) via a root-level
14
+ // `allOf` conditional arm, not an inline constraint on the property, so it
15
+ // is easy to miss. Validated once, here, so every mode builder gets it
16
+ // uniformly -- mirrors python-sdk `_commitment_dict`, policy.py:49-60.
17
+ //
18
+ // Explicit length check, not a truthiness test (Python's `not
19
+ // c.designated_roles`): the omitted-field and supplied-`[]` cases both
20
+ // collapse to length 0 here, and both must throw -- unlike Phase 3's
21
+ // weights rule, there is no supplied-vs-absent distinction to preserve.
22
+ if (commitment?.authority === 'designated_role' && (commitment.designatedRoles?.length ?? 0) === 0) {
23
+ throw new errors_1.MacpSessionError("commitment.authority is 'designated_role' but designatedRoles is empty -- this names no one, " +
24
+ 'so no sender could ever satisfy it. Pass at least one role/participant id in designatedRoles.');
25
+ }
11
26
  return {
12
27
  authority: commitment?.authority ?? 'initiator_only',
13
28
  designated_roles: commitment?.designatedRoles ?? [],
14
- // Parity with python-sdk `_commitment_dict`: emitted for every mode, not
15
- // just decision, so policy JSON is byte-identical across SDKs.
16
- require_vote_quorum: commitment?.requireVoteQuorum ?? false,
29
+ // `require_vote_quorum` and `allow_decline_over_approval` are NOT emitted
30
+ // here -- the canonical *-rules.schema.json commitment object closes with
31
+ // `additionalProperties: false` for every mode except Decision (issue
32
+ // #87 item 1, spec PR #125). Only decision-rules.schema.json's commitment
33
+ // object lists `require_vote_quorum`/`allow_decline_over_approval` as
34
+ // known keys; quorum/proposal/task/handoff-rules.schema.json's commitment
35
+ // object allows only `authority`/`designated_roles`. `buildDecisionPolicy`
36
+ // adds both fields itself, after calling this helper. Emitting them here
37
+ // unconditionally (as this SDK and python-sdk's `_commitment_dict` both
38
+ // used to, for byte-parity with each other rather than with the spec) put
39
+ // a key the other four modes' schemas do not recognize onto the wire --
40
+ // inert under today's runtime (which does not enforce
41
+ // `additionalProperties`) but non-conformant, and a real rejection under
42
+ // any stricter/future validator.
17
43
  };
18
44
  }
19
- function buildDecisionPolicy(policyId, description, rules) {
20
- // Decision-only: extend the shared v1 commitment rules with the schema_version 2
21
- // decline-over-approval switch, appended after the shared keys so it does not
22
- // leak into the still-v1 quorum/proposal/task/handoff commitment blocks
23
- // (parity with python-sdk `_commitment_dict` + `build_decision_policy`).
45
+ // Typed against VotingRules['algorithm'] (not a bare string[]) so adding a
46
+ // seventh canonical algorithm to that union without also adding it here is a
47
+ // tsc error instead of a silent runtime-only rejection — same frozen-set
48
+ // intent as this file's other guards (see e.g. commitment-hash-frozen-fields.test.ts).
49
+ const DECISION_ALGORITHMS = new Set([
50
+ 'none',
51
+ 'majority',
52
+ 'supermajority',
53
+ 'unanimous',
54
+ 'weighted',
55
+ 'plurality',
56
+ ]);
57
+ // RFC-MACP-0012 §8 requires runtimes to accept all three forever -- none of
58
+ // these is a deprecation path. Mirrors macp-sdk-python policy.py's
59
+ // _DECISION_SCHEMA_VERSIONS.
60
+ const DECISION_SCHEMA_VERSIONS = new Set([1, 2, 3]);
61
+ function buildDecisionPolicy(policyId, description, rules, options) {
62
+ const schemaVersion = options?.schemaVersion ?? 3;
63
+ if (!DECISION_SCHEMA_VERSIONS.has(schemaVersion)) {
64
+ throw new errors_1.MacpSessionError(`schemaVersion must be one of ${[...DECISION_SCHEMA_VERSIONS].sort().join(', ')}, got '${schemaVersion}'`);
65
+ }
66
+ const algorithm = rules.voting?.algorithm ?? 'none';
67
+ const threshold = rules.voting?.threshold ?? 0.5;
68
+ const weights = rules.voting?.weights;
69
+ // Match decision-rules.schema.json's constraints before the runtime does,
70
+ // same rationale as buildQuorumPolicy's threshold checks above: a bad
71
+ // descriptor fails immediately client-side instead of round-tripping to an
72
+ // INVALID_POLICY_DEFINITION from RegisterPolicy. Order matters and mirrors
73
+ // macp-sdk-python policy.py:148-180: algorithm enum, then threshold range,
74
+ // then the majority/supermajority asymmetry, then weighted-requires-weights,
75
+ // then the electorate rule (unconditional across every algorithm).
76
+ if (!DECISION_ALGORITHMS.has(algorithm)) {
77
+ throw new errors_1.MacpSessionError(`voting algorithm must be one of ${[...DECISION_ALGORITHMS].sort().join(', ')}, got '${algorithm}'`);
78
+ }
79
+ if (!(threshold > 0 && threshold <= 1)) {
80
+ throw new errors_1.MacpSessionError(`voting threshold must be > 0 and <= 1, got ${threshold}`);
81
+ }
82
+ if (algorithm === 'majority' && threshold < 0.5) {
83
+ throw new errors_1.MacpSessionError(`'majority' requires threshold >= 0.5 (an even split approves), got ${threshold}`);
84
+ }
85
+ // Deliberately asymmetric with the 'majority' check above: policy.std.majority
86
+ // sets threshold exactly 0.5 and RFC-MACP-0012 §2.2 pins that reserved profile
87
+ // byte-identical on every runtime, so an exclusive >= 0.5 bound here would
88
+ // refuse a profile the runtime pre-registers at startup. Do not "fix" this.
89
+ if (algorithm === 'supermajority' && threshold <= 0.5) {
90
+ throw new errors_1.MacpSessionError("'supermajority' requires threshold > 0.5 -- the field's own default of 0.5 is a bare majority " +
91
+ `wearing the name, got ${threshold}. Pass an explicit threshold (e.g. 0.67) for supermajority.`);
92
+ }
93
+ if (algorithm === 'weighted' && (!weights || Object.keys(weights).length === 0)) {
94
+ throw new errors_1.MacpSessionError("'weighted' algorithm requires a non-empty 'weights' map");
95
+ }
96
+ // The weighted electorate is meaningful only when non-empty and strictly
97
+ // positive-valued -- enforced unconditionally, at every algorithm, not only
98
+ // 'weighted' (decision-rules.schema.json: voting.weights minProperties 1,
99
+ // additionalProperties.exclusiveMinimum 0; normative at every schema_version).
100
+ // A weight-0 participant is expressed by omission from this map, never by an
101
+ // explicit 0. `weights !== undefined`, not a truthy check: `{}` is truthy.
102
+ if (weights !== undefined) {
103
+ if (Object.keys(weights).length === 0) {
104
+ throw new errors_1.MacpSessionError("'weights', if provided, must be non-empty");
105
+ }
106
+ for (const [participant, weight] of Object.entries(weights)) {
107
+ // weight <= 0 alone would let a NaN weight slip through (NaN <= 0 is
108
+ // false); the runtime guards this explicitly (registry.rs:596), mirrored
109
+ // here even though the Python reference does not check it separately.
110
+ if (weight <= 0 || Number.isNaN(weight)) {
111
+ throw new errors_1.MacpSessionError(`weights['${participant}'] must be > 0, got ${weight} -- a weight-0 observer is expressed by ` +
112
+ 'omission from the map, not by an explicit 0');
113
+ }
114
+ }
115
+ }
116
+ // Decision-only: extend the shared commitment rules with the two keys
117
+ // decision-rules.schema.json's commitment object allows and the other four
118
+ // modes' commitment schemas do not -- `require_vote_quorum` and the
119
+ // schema_version 2 decline-over-approval switch. Appended after the shared
120
+ // keys so neither leaks into the still-`additionalProperties: false`
121
+ // quorum/proposal/task/handoff commitment blocks (see serializeCommitment).
24
122
  const commitmentSection = serializeCommitment(rules.commitment);
123
+ commitmentSection.require_vote_quorum = rules.commitment?.requireVoteQuorum ?? false;
25
124
  commitmentSection.allow_decline_over_approval = rules.commitment?.allowDeclineOverApproval ?? false;
26
125
  const rulesJson = {
27
126
  voting: {
28
- algorithm: rules.voting?.algorithm ?? 'none',
29
- threshold: rules.voting?.threshold ?? 0.5,
127
+ algorithm,
128
+ threshold,
30
129
  quorum: rules.voting?.quorum ? { type: rules.voting.quorum.type, value: rules.voting.quorum.value } : undefined,
31
- weights: rules.voting?.weights ?? undefined,
130
+ weights,
32
131
  },
33
132
  objection_handling: {
34
133
  critical_severity_vetoes: rules.objectionHandling?.criticalSeverityVetoes ?? false,
@@ -46,22 +145,47 @@ function buildDecisionPolicy(policyId, description, rules) {
46
145
  mode: 'macp.mode.decision.v1',
47
146
  description,
48
147
  rules: JSON.stringify(rulesJson),
49
- schemaVersion: 2,
148
+ schemaVersion,
50
149
  };
51
150
  }
52
151
  function buildQuorumPolicy(policyId, description, rules) {
53
- if (rules.threshold?.type === 'percentage') {
54
- const v = rules.threshold.value;
55
- if (!Number.isInteger(v) || v < 0 || v > 100) {
56
- throw new errors_1.MacpSessionError(`quorum percentage threshold must be an integer in [0, 100] (e.g. 75 for 75%), got ${v}. ` +
57
- 'The runtime computes the approval bar as ceil(value/100 × participants); a fractional value ' +
58
- 'like 0.75 would round to a ~1% bar.');
152
+ const threshold = rules.threshold;
153
+ if (threshold) {
154
+ // Match the canonical quorum-rules schema's constraints before the runtime
155
+ // does, so a bad descriptor fails immediately client-side instead of
156
+ // round-tripping to an INVALID_POLICY_DEFINITION from RegisterPolicy.
157
+ // Order matters: integer first (a float reaching the range checks below
158
+ // would compare fine numerically but produce a confusing message), then
159
+ // > 0, then the percentage-specific <= 100 cap, then type. Mirrors
160
+ // macp-sdk-python policy.py:278-310.
161
+ //
162
+ // Number.isInteger(true) is false in TS, so unlike Python's
163
+ // isinstance(True, int) trap (policy.py:285-287), no separate bool guard
164
+ // is needed here.
165
+ if (!Number.isInteger(threshold.value)) {
166
+ throw new errors_1.MacpSessionError(`quorum threshold value must be an integer (e.g. 75 for 75%), got ${threshold.value}. ` +
167
+ "The canonical quorum-rules schema declares 'value' as an integer for every threshold " +
168
+ 'type; a fractional value like 0.75 would produce a schema-invalid descriptor that the ' +
169
+ 'runtime rejects at RegisterPolicy with worse diagnostics.');
170
+ }
171
+ if (threshold.value <= 0) {
172
+ throw new errors_1.MacpSessionError(`quorum threshold value must be > 0, got ${threshold.value}. A zero approval bar is ` +
173
+ 'trivially satisfied by any ballot set, so the canonical quorum-rules schema declares ' +
174
+ "'value' with exclusiveMinimum 0.");
175
+ }
176
+ if (threshold.type === 'percentage' && threshold.value > 100) {
177
+ throw new errors_1.MacpSessionError(`quorum threshold value must be 1-100 for type 'percentage', got ${threshold.value}`);
178
+ }
179
+ if (threshold.type !== 'n_of_m' && threshold.type !== 'percentage') {
180
+ throw new errors_1.MacpSessionError(`quorum threshold type must be 'n_of_m' or 'percentage', got ${JSON.stringify(threshold.type)}. ` +
181
+ "'weighted' is reserved -- it was removed from the canonical schema without ever having " +
182
+ 'defined semantics and must not be used.');
59
183
  }
60
184
  }
61
185
  const rulesJson = {
62
186
  threshold: {
63
187
  type: rules.threshold?.type ?? 'n_of_m',
64
- value: rules.threshold?.value ?? 0,
188
+ value: rules.threshold?.value ?? 1,
65
189
  },
66
190
  abstention: {
67
191
  counts_toward_quorum: rules.abstention?.countsTowardQuorum ?? false,
@@ -125,6 +125,7 @@ class BaseProjection {
125
125
  // must apply WITHOUT dedup, or a feed of empty-id envelopes collapses to
126
126
  // one. This is architecturally expected (RFC-MACP-0001:306 at-least-once
127
127
  // delivery), not an anomaly — hence `debug`, never `warn`.
128
+ let seenIdAdded = false;
128
129
  if (envelope.messageId) {
129
130
  if (this.seenMessageIds.has(envelope.messageId)) {
130
131
  logging_1.logger.debug('projection redelivery ignored', {
@@ -135,14 +136,32 @@ class BaseProjection {
135
136
  return;
136
137
  }
137
138
  this.seenMessageIds.add(envelope.messageId);
139
+ seenIdAdded = true;
138
140
  }
139
141
  this.transcript.push(envelope);
140
- if (envelope.messageType === 'Commitment') {
141
- this.commitment = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
142
- this.phase = 'Committed';
143
- return;
142
+ // Rollback invariant (parity with macp-sdk-python's base_projection.py
143
+ // apply_envelope): the only fallible operation below is the payload
144
+ // decode. If it throws, `transcript`/`seenMessageIds` must not retain a
145
+ // partial application of this envelope, or a legitimate retry of the
146
+ // same `message_id` is silently swallowed as a redelivery and the
147
+ // envelope's effect is lost forever. Deliberately narrow -- this rolls
148
+ // back only the two mutations made above, never mode/subclass-owned
149
+ // state, which is safe only because every projection performs its one
150
+ // fallible operation (decode) strictly before any state mutation.
151
+ try {
152
+ if (envelope.messageType === 'Commitment') {
153
+ this.commitment = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
154
+ this.phase = 'Committed';
155
+ return;
156
+ }
157
+ this.applyMode(envelope, protoRegistry);
158
+ }
159
+ catch (err) {
160
+ this.transcript.pop();
161
+ if (seenIdAdded)
162
+ this.seenMessageIds.delete(envelope.messageId);
163
+ throw err;
144
164
  }
145
- this.applyMode(envelope, protoRegistry);
146
165
  }
147
166
  /**
148
167
  * Record a cardinality anomaly: push it onto `anomalies` and emit it via
@@ -1,4 +1,4 @@
1
- import type { ProjectionAnomaly } from './base';
1
+ import { BaseProjection } from './base';
2
2
  import type { Envelope } from '../types';
3
3
  import type { ProtoRegistry } from '../proto-registry';
4
4
  export interface DecisionProposalRecord {
@@ -26,59 +26,15 @@ export interface DecisionVoteRecord {
26
26
  reason?: string;
27
27
  sender: string;
28
28
  }
29
- export declare class DecisionProjection {
29
+ export declare class DecisionProjection extends BaseProjection {
30
+ protected readonly mode = "macp.mode.decision.v1";
30
31
  readonly proposals: Map<string, DecisionProposalRecord>;
31
32
  readonly evaluations: DecisionEvaluationRecord[];
32
33
  readonly objections: DecisionObjectionRecord[];
33
34
  readonly votes: Map<string, Map<string, DecisionVoteRecord>>;
34
- /**
35
- * The session's accepted history, one envelope per unique `message_id`. See
36
- * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
37
- * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
38
- * only as a one-line pointer.
39
- */
40
- readonly transcript: Envelope[];
41
- /**
42
- * Cardinality anomalies recorded while replaying this projection's accepted
43
- * transcript (e.g. a duplicate `Vote` from the same sender for the same
44
- * `proposal_id`). See `BaseProjection.anomalies` (`src/projections/base.ts`)
45
- * for the canonical description; duplicated here only as a one-line
46
- * pointer.
47
- */
48
- readonly anomalies: ProjectionAnomaly[];
49
35
  phase: 'Proposal' | 'Evaluation' | 'Voting' | 'Committed';
50
- commitment?: Record<string, unknown>;
51
- /**
52
- * `message_id`s already applied to this projection. See
53
- * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
54
- * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
55
- * only as a one-line pointer.
56
- */
57
- private readonly seenMessageIds;
58
- /**
59
- * Apply one envelope to this projection's in-process state.
60
- *
61
- * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
62
- * no acceptance marker). Canonical source: `schemas/conformance/README.md`
63
- * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
64
- * mode are documented once, on `BaseProjection.applyEnvelope`
65
- * (`src/projections/base.ts`), and duplicated here only as a one-line
66
- * pointer so six independent copies of the same prose cannot drift.
67
- *
68
- * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
69
- * non-event — not appended to `transcript`, not passed to the `switch`.
70
- * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
71
- * citation; duplicated here only as a one-line pointer.
72
- */
73
- applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
74
- /**
75
- * True once at least one `ProjectionAnomaly` has been recorded. See
76
- * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
77
- * canonical description; duplicated here only as a one-line pointer.
78
- */
79
- get hasAnomalies(): boolean;
80
- get isCommitted(): boolean;
81
- get isPositiveOutcome(): boolean | undefined;
36
+ /** Handle a Decision-mode envelope. See `BaseProjection.applyEnvelope` for the accepted-only input contract and redelivery/rollback semantics shared by all built-in modes. */
37
+ protected applyMode(envelope: Envelope, protoRegistry: ProtoRegistry): void;
82
38
  voteTotals(): Record<string, number>;
83
39
  majorityWinner(): string | undefined;
84
40
  /** Returns the APPROVE vote ratio excluding ABSTAIN votes from the denominator. */