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 +107 -0
- package/README.md +1 -1
- package/dist/client.d.ts +9 -0
- package/dist/client.js +10 -0
- package/dist/policy.d.ts +54 -7
- package/dist/policy.js +143 -19
- package/dist/projections/base.js +24 -5
- package/dist/projections/decision.d.ts +5 -49
- package/dist/projections/decision.js +7 -82
- package/dist/projections/handoff.d.ts +5 -48
- package/dist/projections/handoff.js +5 -77
- package/dist/projections/proposal.d.ts +5 -48
- package/dist/projections/proposal.js +5 -77
- package/dist/projections/quorum.d.ts +5 -49
- package/dist/projections/quorum.js +7 -82
- package/dist/projections/task.d.ts +21 -44
- package/dist/projections/task.js +66 -92
- package/dist/proto-registry.d.ts +19 -5
- package/dist/proto-registry.js +24 -16
- package/dist/validation.d.ts +20 -0
- package/dist/validation.js +32 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
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
|
|
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
|
-
/**
|
|
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'
|
|
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
|
|
57
|
-
* - `percentage`: an **integer
|
|
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`.
|
|
60
|
-
*
|
|
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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
29
|
-
threshold
|
|
127
|
+
algorithm,
|
|
128
|
+
threshold,
|
|
30
129
|
quorum: rules.voting?.quorum ? { type: rules.voting.quorum.type, value: rules.voting.quorum.value } : undefined,
|
|
31
|
-
weights
|
|
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
|
|
148
|
+
schemaVersion,
|
|
50
149
|
};
|
|
51
150
|
}
|
|
52
151
|
function buildQuorumPolicy(policyId, description, rules) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 ??
|
|
188
|
+
value: rules.threshold?.value ?? 1,
|
|
65
189
|
},
|
|
66
190
|
abstention: {
|
|
67
191
|
counts_toward_quorum: rules.abstention?.countsTowardQuorum ?? false,
|
package/dist/projections/base.js
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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. */
|