macp-sdk-typescript 0.8.0 → 0.9.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,89 @@ 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.9.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.8.0...v0.9.0) (2026-09-01)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * **projections:** ProposalProjection.isAccepted()/acceptedProposal(), TaskProjection's `assignee` field, and HandoffProjection's `status` field can now derive different values than before for a transcript containing the message sequences described above. Conforming transcripts (no superseded accept, no second TaskAccept/contradictory handoff accept-decline) are unaffected.
13
+
14
+ ### Bug Fixes
15
+
16
+ * **agent:** don't count an envelope into the resume cursor until it's asked for again ([#72](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/72)) ([f1b7015](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/f1b70155cb3293bc7f36c0389cbf880359a01e58)), closes [#66](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/66)
17
+ * **agent:** resume GrpcTransportAdapter from its own cursor, and count distinct envelopes ([#65](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/65)) ([7535763](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/75357632351addde3a32a61b1200e823f2ef70da))
18
+ * **projections:** enforce cardinality rules issues [#59](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/59)/[#60](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/60) said didn't exist ([#68](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/68)) ([956d881](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/956d881e19591bcd9a04c6496bff07213d1ee6d3))
19
+
20
+ ## [Unreleased]
21
+
22
+ ### Fixed
23
+
24
+ - **BREAKING: four projection sites now enforce the cardinality rule their
25
+ governing RFC section actually states, instead of last-write-wins.**
26
+ Issues #59 and #60 argued that no cardinality/ordering site in the
27
+ built-in projections had a normative rule behind it; a full re-read of the
28
+ four mode RFCs plus RFC-MACP-0001 found governing rules for five of the
29
+ ten sites they raised, three of which were shipped conformance
30
+ violations:
31
+ - `ProposalProjection` (breaking): a later `Accept` from a participant now
32
+ supersedes their earlier one (RFC-MACP-0008 §5 rule 5, `:70`), so
33
+ `isAccepted` and `acceptedProposal()` read the live acceptance set
34
+ instead of raw accept history. Previously `acceptedProposal()` could
35
+ return `undefined` after a legal re-accept, where the RFC's
36
+ determinism clause (§7, `:89`) makes the accepted proposal unambiguous.
37
+ - `TaskProjection` (breaking): the first accepted `TaskAccept` now wins —
38
+ a later `TaskAccept` for the same task no longer overwrites `assignee`
39
+ (RFC-MACP-0009 §5 rules 3/3a, `:69-71`). The policy-gated reassignment
40
+ path (rule 3c) is intentionally not modelled yet; `TaskProjection` has
41
+ no session-policy input.
42
+ - `HandoffProjection` (breaking): a `handoff_id` now settles (`offered` →
43
+ `accepted`/`declined`) exactly once — a later contradictory
44
+ `HandoffAccept`/`HandoffDecline` for an already-settled `handoff_id`,
45
+ or for a `handoff_id` that was never offered, is ignored in both
46
+ directions and can no longer mutate `phase` (RFC-MACP-0010 §5 rule 2
47
+ `:65`, rule 4 `:68`, §5.1(4) `:113-116`).
48
+ - `DecisionProjection` (not breaking for conforming callers): `phase` no
49
+ longer regresses out of `'Committed'` if a `Vote` is replayed after a
50
+ `Commitment` — such a message cannot legally exist in accepted history
51
+ under a conforming runtime (RFC-MACP-0001 §7.2 `:218`, §7.3
52
+ `:240`/`:249`), but a caller violating the accepted-only contract no
53
+ longer corrupts `phase` if it happens.
54
+
55
+ `ProposalProjection.isAccepted()`/`acceptedProposal()`, `TaskProjection`'s
56
+ `assignee` field, and `HandoffProjection`'s `status`/`phase` can now
57
+ derive different values than before for a transcript containing the
58
+ message sequences described above — existing callers reading those
59
+ surfaces should treat this as a breaking change. Conforming transcripts
60
+ (no superseded accept, no second `TaskAccept`, no contradictory or
61
+ unknown-`handoff_id` handoff accept/decline) are unaffected.
62
+
63
+ Three other sites raised by #59 (Decision `Evaluation`/`Objection`
64
+ cardinality, Task `TaskUpdate` accumulation) were confirmed to be silent
65
+ *coherently* and are unchanged. `TaskComplete`/`TaskFail` cardinality is
66
+ genuinely ambiguous in RFC-MACP-0009 and is tracked as an upstream spec
67
+ question rather than an SDK change.
68
+
69
+ ### Changed
70
+
71
+ - **`GrpcTransportAdapter` resumes instead of replaying on a second `start()`.**
72
+ The adapter now calls `sendSubscribe(sessionId, this.lastSequence)` instead of
73
+ always subscribing from `0`, so a caller that `stop()`s and re-`start()`s a
74
+ reused adapter — the only reachable "reconnect" in this SDK; there is no
75
+ built-in retry loop — no longer replays the entire session history. Requires
76
+ `macp-runtime` ≥ 0.5.0 for the `after_sequence` ordinal contract
77
+ (RFC-MACP-0006 §3.2); older runtimes compared it inclusively against a raw
78
+ log index. `lastSequence` itself is corrected in the same change to count
79
+ **distinct** accepted envelopes (keyed on `message_id`) instead of raw
80
+ delivery events — passing the old, uncorrected counter as a resume cursor
81
+ would have silently skipped history on resume (RFC-MACP-0006 §3.2
82
+ Redelivery). Anyone relying on a reused adapter's second `start()` to rebuild
83
+ a *fresh* projection from full history will now see only the envelopes
84
+ accepted since the first pass ended; open a new `GrpcTransportAdapter` (or a
85
+ raw `MacpStream` with `sendSubscribe(sessionId, 0)`) for a full replay.
86
+ - `MacpStream.sendSubscribe`'s docblock no longer claims `IncomingMessage.seq`
87
+ is the RFC-MACP-0006 ordinal — `seq` is a 0-based delivery index that
88
+ advances on redelivery, unlike the ordinal.
89
+
7
90
  ## [0.8.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.7.1...v0.8.0) (2026-08-31)
8
91
 
9
92
 
package/README.md CHANGED
@@ -367,7 +367,7 @@ await stream.sendSubscribe(sessionId); // full replay then live
367
367
  await stream.sendSubscribe(sessionId, lastSeq); // resume from cursor
368
368
  ```
369
369
 
370
- The agent framework's `GrpcTransportAdapter` calls this for you on start, so non-initiator agents see `SessionStart` regardless of join order.
370
+ The agent framework's `GrpcTransportAdapter` calls this for you on start, so non-initiator agents see `SessionStart` regardless of join order — and it passes its own resume cursor on a second `start()` (after a `stop()`), so that call resumes rather than replaying the whole session again.
371
371
 
372
372
  ### Registry & Root Watchers
373
373
 
@@ -12,14 +12,39 @@ export declare class GrpcTransportAdapter implements TransportAdapter {
12
12
  private stream;
13
13
  private seq;
14
14
  private delivered;
15
+ /**
16
+ * `message_id`s already counted into `delivered`, so the resume cursor
17
+ * advances once per distinct accepted envelope rather than once per raw
18
+ * delivery (RFC-MACP-0006 §3.2 Redelivery). Deliberately unbounded, for the
19
+ * same reason `BaseProjection` leaves its own copy of this set unbounded
20
+ * (`src/projections/base.ts:118-129`): this set is strictly dominated by
21
+ * memory this SDK already commits to per session — a `Participant` using
22
+ * this adapter also holds a `BaseProjection` with an identical
23
+ * `message_id` set *plus* a full `transcript` of every envelope, payload
24
+ * bytes included. The runtime keeps an unbounded per-session dedup set of
25
+ * its own regardless of what any client does
26
+ * (`macp-runtime/crates/macp-modes/src/step.rs:48/:89`, field declared at
27
+ * `macp-runtime/crates/macp-core/src/session.rs:69`), and sessions are
28
+ * TTL-bounded by protocol.
29
+ */
30
+ private readonly seenMessageIds;
15
31
  constructor(client: MacpClient, sessionId: string, auth?: AuthConfig | undefined);
16
32
  /**
17
33
  * The server passive-subscribe ordinal of the last delivered envelope (the
18
- * 1-based count of accepted envelopes delivered on this stream, RFC-MACP-0006
19
- * §3.2). `0` before anything is delivered. Pass this as `afterSequence` to
20
- * `MacpStream.sendSubscribe` on a reconnect to resume exactly-once — ordinals
21
- * are stable across compaction/restart. Distinct from `IncomingMessage.seq`,
22
- * which is a client-local 0-based delivery index.
34
+ * 1-based count of *distinct* accepted envelopes delivered on this stream,
35
+ * RFC-MACP-0006 §3.2). `0` before anything is delivered. `start()` passes
36
+ * this value as `afterSequence` to `MacpStream.sendSubscribe` itself, so a
37
+ * caller that `stop()`s and calls `start()` again (the only reachable
38
+ * "reconnect" in this SDK — there is no built-in retry loop) resumes
39
+ * rather than replays the whole session. Ordinals are stable across
40
+ * compaction/restart. Counts distinct `message_id`s, never raw delivery
41
+ * events: RFC-MACP-0006 §3.2 Redelivery requires that "a redelivery MUST
42
+ * NOT advance the client's sequence position; only a distinct accepted
43
+ * envelope does," and a client that counts raw deliveries "arrives at a
44
+ * position ahead of the true one, and its next resume silently skips
45
+ * history." Distinct from `IncomingMessage.seq`, which is a client-local
46
+ * 0-based delivery index that advances on every delivery, redeliveries
47
+ * included.
23
48
  */
24
49
  get lastSequence(): number;
25
50
  start(): AsyncIterable<IncomingMessage>;
@@ -19,6 +19,22 @@ class GrpcTransportAdapter {
19
19
  stream = null;
20
20
  seq = 0;
21
21
  delivered = 0;
22
+ /**
23
+ * `message_id`s already counted into `delivered`, so the resume cursor
24
+ * advances once per distinct accepted envelope rather than once per raw
25
+ * delivery (RFC-MACP-0006 §3.2 Redelivery). Deliberately unbounded, for the
26
+ * same reason `BaseProjection` leaves its own copy of this set unbounded
27
+ * (`src/projections/base.ts:118-129`): this set is strictly dominated by
28
+ * memory this SDK already commits to per session — a `Participant` using
29
+ * this adapter also holds a `BaseProjection` with an identical
30
+ * `message_id` set *plus* a full `transcript` of every envelope, payload
31
+ * bytes included. The runtime keeps an unbounded per-session dedup set of
32
+ * its own regardless of what any client does
33
+ * (`macp-runtime/crates/macp-modes/src/step.rs:48/:89`, field declared at
34
+ * `macp-runtime/crates/macp-core/src/session.rs:69`), and sessions are
35
+ * TTL-bounded by protocol.
36
+ */
37
+ seenMessageIds = new Set();
22
38
  constructor(client, sessionId, auth) {
23
39
  this.client = client;
24
40
  this.sessionId = sessionId;
@@ -26,27 +42,92 @@ class GrpcTransportAdapter {
26
42
  }
27
43
  /**
28
44
  * The server passive-subscribe ordinal of the last delivered envelope (the
29
- * 1-based count of accepted envelopes delivered on this stream, RFC-MACP-0006
30
- * §3.2). `0` before anything is delivered. Pass this as `afterSequence` to
31
- * `MacpStream.sendSubscribe` on a reconnect to resume exactly-once — ordinals
32
- * are stable across compaction/restart. Distinct from `IncomingMessage.seq`,
33
- * which is a client-local 0-based delivery index.
45
+ * 1-based count of *distinct* accepted envelopes delivered on this stream,
46
+ * RFC-MACP-0006 §3.2). `0` before anything is delivered. `start()` passes
47
+ * this value as `afterSequence` to `MacpStream.sendSubscribe` itself, so a
48
+ * caller that `stop()`s and calls `start()` again (the only reachable
49
+ * "reconnect" in this SDK — there is no built-in retry loop) resumes
50
+ * rather than replays the whole session. Ordinals are stable across
51
+ * compaction/restart. Counts distinct `message_id`s, never raw delivery
52
+ * events: RFC-MACP-0006 §3.2 Redelivery requires that "a redelivery MUST
53
+ * NOT advance the client's sequence position; only a distinct accepted
54
+ * envelope does," and a client that counts raw deliveries "arrives at a
55
+ * position ahead of the true one, and its next resume silently skips
56
+ * history." Distinct from `IncomingMessage.seq`, which is a client-local
57
+ * 0-based delivery index that advances on every delivery, redeliveries
58
+ * included.
34
59
  */
35
60
  get lastSequence() {
36
61
  return this.delivered;
37
62
  }
38
63
  async *start() {
64
+ // A prior stream from an earlier start() (without an intervening stop())
65
+ // must not keep feeding this adapter's counter — two live generators
66
+ // would both advance `delivered` from two differently-positioned
67
+ // replays. `MacpStream.close()` is `this.call.end()` (`src/client.ts:223-227`),
68
+ // a write-side half-close — it does not itself force the server to stop
69
+ // pushing. It is enough here because macp-runtime's StreamSession loop
70
+ // reads the resulting end-of-request-stream as `StreamAction::ClientDone`,
71
+ // whose arm drains any already-buffered envelopes once and then breaks,
72
+ // ending that stream's response side too
73
+ // (macp-runtime/src/server.rs:654-666). Close it before opening the new
74
+ // one so re-entrant start() and stop()-then-start() behave identically.
75
+ if (this.stream) {
76
+ this.stream.close();
77
+ this.stream = null;
78
+ }
39
79
  this.stream = this.client.openStream({ auth: this.auth });
40
- // RFC-MACP-0006-A1: Subscribe to the session with history replay.
41
- // The runtime replays accepted envelopes then switches to live broadcast.
42
- // This ensures non-initiator agents receive the SessionStart + Proposal
43
- // regardless of spawn order or connection timing.
44
- await this.stream.sendSubscribe(this.sessionId);
80
+ // RFC-MACP-0006 §3.2: subscribe to the session, resuming from this
81
+ // adapter's own cursor. `this.delivered` is 0 before anything has been
82
+ // delivered, by construction, and `afterSequence = 0` is normatively
83
+ // "replay from the session's first accepted envelope" — so the first
84
+ // subscribe and every later one (e.g. after stop()+start()) are the same
85
+ // expression. There is no separate first-subscribe/reconnect branch to
86
+ // keep in sync with the counter.
87
+ await this.stream.sendSubscribe(this.sessionId, this.delivered);
45
88
  for await (const envelope of this.stream.responses()) {
46
89
  if (envelope.sessionId !== this.sessionId)
47
90
  continue;
48
- this.delivered++;
49
- yield normalizeEnvelope(envelope, (mode, mt, p) => this.client.protoRegistry.decodeKnownPayload(mode, mt, p), this.seq++);
91
+ // Count this envelope into the resume cursor only *after* the
92
+ // consumer has come back for more, not when it is handed off. `yield`
93
+ // suspends here until the consumer's `for await` either calls
94
+ // `.next()` again (it took this envelope and kept iterating — the
95
+ // code below runs) or closes the iterator via `break`/`return`/throw
96
+ // (e.g. `Participant.run()` observing `!this.running` and breaking
97
+ // out of its loop without processing this envelope). In the latter
98
+ // case `.return()` on this generator resumes at this `yield` as if a
99
+ // `return` had been written here, so nothing below it runs: the
100
+ // cursor is never advanced past an envelope the consumer never
101
+ // actually took past the loop boundary, and the next `start()`
102
+ // redelivers it instead of silently skipping it. See #66 — counting
103
+ // before yielding let a stop-mid-stream permanently drop an envelope
104
+ // once #65 made the resume cursor authoritative.
105
+ const message = normalizeEnvelope(envelope, (mode, mt, p) => this.client.protoRegistry.decodeKnownPayload(mode, mt, p), this.seq++);
106
+ yield message;
107
+ // RFC-MACP-0006 §3.2 Redelivery: a redelivery MUST NOT advance the
108
+ // resume cursor, and a consumer that accumulates state per envelope
109
+ // MUST be idempotent w.r.t. `message_id`. An empty/absent messageId
110
+ // has no identity to dedup on and increments unconditionally — the
111
+ // same carve-out the projection guard documents at
112
+ // `src/projections/base.ts:224-227`, so the two dedup sites read the
113
+ // same way. In practice the `else` below can never run against a
114
+ // conformant runtime: `validate_envelope_shape` rejects any envelope
115
+ // with an empty `message_id` as `InvalidEnvelope` before it can be
116
+ // accepted (`macp-runtime/src/server.rs:118`), and RFC-MACP-0001 §8.2
117
+ // makes `message_id` the runtime's dedup identity, so no accepted
118
+ // envelope in any session history can carry one. The branch stays
119
+ // anyway — it costs nothing and errs toward still counting the
120
+ // envelope instead of silently dropping it if a non-conformant server
121
+ // ever sends one.
122
+ if (envelope.messageId) {
123
+ if (!this.seenMessageIds.has(envelope.messageId)) {
124
+ this.seenMessageIds.add(envelope.messageId);
125
+ this.delivered++;
126
+ }
127
+ }
128
+ else {
129
+ this.delivered++;
130
+ }
50
131
  }
51
132
  }
52
133
  async stop() {
package/dist/client.d.ts CHANGED
@@ -48,9 +48,13 @@ export declare class MacpStream {
48
48
  *
49
49
  * `afterSequence` is the **1-based accepted-envelope ordinal**, exclusive:
50
50
  * `0` (default) replays from the very start; `k` replays envelopes with
51
- * ordinal `> k`. Clients derive the ordinal by counting delivered envelopes
52
- * (the Nth accepted envelope has ordinal N) — see
53
- * `IncomingMessage.seq`, which is exactly this ordinal under the new contract.
51
+ * ordinal `> k`. Clients derive the ordinal by counting **distinct**
52
+ * accepted envelopes delivered, keyed on `message_id` — RFC-MACP-0006 §3.2
53
+ * Redelivery requires that a repeat of an already-observed envelope MUST
54
+ * NOT advance the count. This is **not** the same as `IncomingMessage.seq`,
55
+ * which is a client-local 0-based delivery index that advances on every
56
+ * delivery, redeliveries included; see `GrpcTransportAdapter.lastSequence`
57
+ * for a cursor that tracks the ordinal correctly.
54
58
  * Ordinals are stable across log compaction and runtime restart. Resuming
55
59
  * below a compacted base returns `FAILED_PRECONDITION` (inspectable via
56
60
  * `MacpTransportError.code`) rather than silently skipping history (runtime
package/dist/client.js CHANGED
@@ -160,9 +160,13 @@ class MacpStream {
160
160
  *
161
161
  * `afterSequence` is the **1-based accepted-envelope ordinal**, exclusive:
162
162
  * `0` (default) replays from the very start; `k` replays envelopes with
163
- * ordinal `> k`. Clients derive the ordinal by counting delivered envelopes
164
- * (the Nth accepted envelope has ordinal N) — see
165
- * `IncomingMessage.seq`, which is exactly this ordinal under the new contract.
163
+ * ordinal `> k`. Clients derive the ordinal by counting **distinct**
164
+ * accepted envelopes delivered, keyed on `message_id` — RFC-MACP-0006 §3.2
165
+ * Redelivery requires that a repeat of an already-observed envelope MUST
166
+ * NOT advance the count. This is **not** the same as `IncomingMessage.seq`,
167
+ * which is a client-local 0-based delivery index that advances on every
168
+ * delivery, redeliveries included; see `GrpcTransportAdapter.lastSequence`
169
+ * for a cursor that tracks the ordinal correctly.
166
170
  * Ordinals are stable across log compaction and runtime restart. Resuming
167
171
  * below a compacted base returns `FAILED_PRECONDITION` (inspectable via
168
172
  * `MacpTransportError.code`) rather than silently skipping history (runtime
@@ -109,7 +109,23 @@ class DecisionProjection {
109
109
  }
110
110
  bySender.set(envelope.sender, { ...record, sender: envelope.sender });
111
111
  this.votes.set(record.proposalId, bySender);
112
- this.phase = 'Voting';
112
+ // RFC-MACP-0001 §7.2 (`:218`): RESOLVED is terminal and sessions
113
+ // MUST transition monotonically w.r.t. termination — never back to
114
+ // OPEN/SUSPENDED. §7.3 (`:240`, restated `:249`) says a conforming
115
+ // runtime rejects any session-scoped message once the session is
116
+ // non-OPEN, so a `Vote` cannot legally follow a `Commitment` in
117
+ // accepted history. If one reaches here anyway (the caller violated
118
+ // the accepted-only contract — see `applyEnvelope`'s docblock), do
119
+ // not regress `phase` out of `'Committed'`. Note "phase" itself is
120
+ // not a normative MACP term (it appears once, in passing, at
121
+ // RFC-MACP-0012 `:211`); this guard is justified by session
122
+ // terminality, not by a phase specification. An anomaly would be
123
+ // recorded here too, but `ProjectionAnomalyKind` (`base.ts:8-9`) is
124
+ // deliberately frozen pending cross-SDK agreement with
125
+ // macp-sdk-python.
126
+ if (this.phase !== 'Committed') {
127
+ this.phase = 'Voting';
128
+ }
113
129
  break;
114
130
  }
115
131
  case 'Commitment': {
@@ -91,25 +91,61 @@ class HandoffProjection {
91
91
  case 'HandoffAccept': {
92
92
  const record = payload;
93
93
  const handoff = this.handoffs.get(record.handoffId);
94
- if (handoff) {
94
+ if (!handoff) {
95
+ // RFC-MACP-0010 §5 rule 2 (`:65`): "HandoffContext, HandoffAccept,
96
+ // and HandoffDecline MUST reference an existing handoff_id." An
97
+ // accept for an unknown/never-offered handoff_id is invalid input
98
+ // (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
99
+ // fabricate a handoff record. An anomaly would be recorded here,
100
+ // but `ProjectionAnomalyKind` (`base.ts:8-9`) is deliberately
101
+ // frozen pending cross-SDK agreement with macp-sdk-python.
102
+ break;
103
+ }
104
+ // RFC-MACP-0010 §5 rule 4 (`:68`): "Once an offer has been
105
+ // accepted, no competing accept for that same `handoff_id` is
106
+ // valid." §5.1(4) (`:113-116`) settles the decline-after-accept
107
+ // direction too: a `handoff_id` transitions
108
+ // offered -> accepted | declined exactly once. Only settle if this
109
+ // handoff hasn't already resolved — the same shape as the
110
+ // HandoffContext guard just above. An anomaly would be recorded when
111
+ // this guard discards a competing accept, but `ProjectionAnomalyKind`
112
+ // (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
113
+ // with macp-sdk-python.
114
+ if (handoff.status === 'offered' || handoff.status === 'context_sent') {
95
115
  handoff.status = 'accepted';
96
116
  handoff.acceptedBy = record.acceptedBy;
97
117
  // proto3 bool defaults are materialized to `false` on decode
98
118
  // (proto-registry), so this is always a real boolean once proto 0.1.6
99
119
  // is loaded — `true` marks a runtime synthetic implicit accept.
100
120
  handoff.implicit = record.implicit ?? false;
121
+ this.phase = 'Accepted';
101
122
  }
102
- this.phase = 'Accepted';
103
123
  break;
104
124
  }
105
125
  case 'HandoffDecline': {
106
126
  const record = payload;
107
127
  const handoff = this.handoffs.get(record.handoffId);
108
- if (handoff) {
128
+ if (!handoff) {
129
+ // RFC-MACP-0010 §5 rule 2 (`:65`): "HandoffContext, HandoffAccept,
130
+ // and HandoffDecline MUST reference an existing handoff_id." A
131
+ // decline for an unknown/never-offered handoff_id is invalid input
132
+ // (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
133
+ // fabricate a handoff record. An anomaly would be recorded here,
134
+ // but `ProjectionAnomalyKind` (`base.ts:8-9`) is deliberately
135
+ // frozen pending cross-SDK agreement with macp-sdk-python.
136
+ break;
137
+ }
138
+ // RFC-MACP-0010 §5 rule 4 (`:68`) + §5.1(4) (`:113-116`): a
139
+ // `handoff_id` settles once. A decline after the handoff already
140
+ // settled (accepted or declined) is invalid and ignored. An anomaly
141
+ // would be recorded here too, but `ProjectionAnomalyKind`
142
+ // (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
143
+ // with macp-sdk-python.
144
+ if (handoff.status === 'offered' || handoff.status === 'context_sent') {
109
145
  handoff.status = 'declined';
110
146
  handoff.declinedBy = record.declinedBy;
147
+ this.phase = 'Declined';
111
148
  }
112
- this.phase = 'Declined';
113
149
  break;
114
150
  }
115
151
  case 'Commitment': {
@@ -25,6 +25,20 @@ export declare class ProposalProjection {
25
25
  readonly proposals: Map<string, ProposalRecord>;
26
26
  readonly accepts: ProposalAcceptRecord[];
27
27
  readonly rejections: ProposalRejectRecord[];
28
+ /**
29
+ * Each sender's current (unsuperseded) `Accept`, keyed by sender.
30
+ * RFC-MACP-0008 §5 rule 5 (`:70`): "A participant MAY change its
31
+ * acceptance target by sending a later `Accept` for a different live
32
+ * proposal. The latest accepted `Accept` from a participant supersedes
33
+ * earlier accepts from the same participant." `accepts` above retains the
34
+ * full append-only history (including superseded accepts) for audit
35
+ * purposes; `isAccepted` and `acceptedProposal` read from this map instead
36
+ * so they reflect the live acceptance set — required for determinism by
37
+ * §7 (`:89`): "Given the same accepted history and the same version-bound
38
+ * rules, implementations MUST derive the same live proposal set, the same
39
+ * acceptance set, and the same commitment eligibility."
40
+ */
41
+ private readonly latestAcceptBySender;
28
42
  /**
29
43
  * The session's accepted history, one envelope per unique `message_id`. See
30
44
  * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
@@ -74,9 +88,25 @@ export declare class ProposalProjection {
74
88
  get isPositiveOutcome(): boolean | undefined;
75
89
  activeProposals(): ProposalRecord[];
76
90
  latestProposal(): ProposalRecord | undefined;
91
+ /**
92
+ * True if some participant's *current* (unsuperseded) `Accept` targets
93
+ * `proposalId` — RFC-MACP-0008 §5 rule 5 (`:70`). Note this reads from the
94
+ * live acceptance set, not from `accepts`' full history: a participant
95
+ * whose only accept for `proposalId` was later superseded by a re-accept
96
+ * of a different proposal is not counted.
97
+ */
77
98
  isAccepted(proposalId: string): boolean;
78
99
  isTerminallyRejected(proposalId: string): boolean;
79
100
  liveProposals(): Map<string, ProposalRecord>;
101
+ /**
102
+ * The single proposal every participant's *current* accept targets, or
103
+ * `undefined` if no one has an outstanding accept or outstanding accepts
104
+ * are split across more than one proposal. Computed from the live
105
+ * acceptance set (see `isAccepted`), not from `accepts`' full history — a
106
+ * participant who accepts `p1` and later re-accepts `p2` (RFC-MACP-0008 §5
107
+ * rule 5, `:70`) contributes only `p2`, so this correctly returns `p2`
108
+ * rather than `undefined`.
109
+ */
80
110
  acceptedProposal(): string | undefined;
81
111
  hasTerminalRejection(): boolean;
82
112
  }
@@ -7,6 +7,20 @@ class ProposalProjection {
7
7
  proposals = new Map();
8
8
  accepts = [];
9
9
  rejections = [];
10
+ /**
11
+ * Each sender's current (unsuperseded) `Accept`, keyed by sender.
12
+ * RFC-MACP-0008 §5 rule 5 (`:70`): "A participant MAY change its
13
+ * acceptance target by sending a later `Accept` for a different live
14
+ * proposal. The latest accepted `Accept` from a participant supersedes
15
+ * earlier accepts from the same participant." `accepts` above retains the
16
+ * full append-only history (including superseded accepts) for audit
17
+ * purposes; `isAccepted` and `acceptedProposal` read from this map instead
18
+ * so they reflect the live acceptance set — required for determinism by
19
+ * §7 (`:89`): "Given the same accepted history and the same version-bound
20
+ * rules, implementations MUST derive the same live proposal set, the same
21
+ * acceptance set, and the same commitment eligibility."
22
+ */
23
+ latestAcceptBySender = new Map();
10
24
  /**
11
25
  * The session's accepted history, one envelope per unique `message_id`. See
12
26
  * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
@@ -88,7 +102,11 @@ class ProposalProjection {
88
102
  }
89
103
  case 'Accept': {
90
104
  const record = payload;
91
- this.accepts.push({ ...record, sender: envelope.sender });
105
+ const accept = { ...record, sender: envelope.sender };
106
+ this.accepts.push(accept);
107
+ // RFC-MACP-0008 §5 rule 5 (`:70`): this Accept supersedes any earlier
108
+ // one from the same sender for the live acceptance set.
109
+ this.latestAcceptBySender.set(envelope.sender, accept);
92
110
  break;
93
111
  }
94
112
  case 'Reject': {
@@ -149,8 +167,19 @@ class ProposalProjection {
149
167
  const all = [...this.proposals.values()];
150
168
  return all[all.length - 1];
151
169
  }
170
+ /**
171
+ * True if some participant's *current* (unsuperseded) `Accept` targets
172
+ * `proposalId` — RFC-MACP-0008 §5 rule 5 (`:70`). Note this reads from the
173
+ * live acceptance set, not from `accepts`' full history: a participant
174
+ * whose only accept for `proposalId` was later superseded by a re-accept
175
+ * of a different proposal is not counted.
176
+ */
152
177
  isAccepted(proposalId) {
153
- return this.accepts.some((a) => a.proposalId === proposalId);
178
+ for (const accept of this.latestAcceptBySender.values()) {
179
+ if (accept.proposalId === proposalId)
180
+ return true;
181
+ }
182
+ return false;
154
183
  }
155
184
  isTerminallyRejected(proposalId) {
156
185
  return this.rejections.some((r) => r.proposalId === proposalId && r.terminal);
@@ -163,10 +192,19 @@ class ProposalProjection {
163
192
  }
164
193
  return result;
165
194
  }
195
+ /**
196
+ * The single proposal every participant's *current* accept targets, or
197
+ * `undefined` if no one has an outstanding accept or outstanding accepts
198
+ * are split across more than one proposal. Computed from the live
199
+ * acceptance set (see `isAccepted`), not from `accepts`' full history — a
200
+ * participant who accepts `p1` and later re-accepts `p2` (RFC-MACP-0008 §5
201
+ * rule 5, `:70`) contributes only `p2`, so this correctly returns `p2`
202
+ * rather than `undefined`.
203
+ */
166
204
  acceptedProposal() {
167
- if (this.accepts.length === 0)
205
+ if (this.latestAcceptBySender.size === 0)
168
206
  return undefined;
169
- const ids = new Set(this.accepts.map((a) => a.proposalId));
207
+ const ids = new Set([...this.latestAcceptBySender.values()].map((a) => a.proposalId));
170
208
  if (ids.size === 1)
171
209
  return ids.values().next().value;
172
210
  return undefined;
@@ -81,7 +81,30 @@ class TaskProjection {
81
81
  case 'TaskAccept': {
82
82
  const record = payload;
83
83
  const task = this.tasks.get(record.taskId);
84
- if (task) {
84
+ // RFC-MACP-0009 §5 rules 3/3a (`:69-71`): "Only one assignee may
85
+ // become active for the Session in base v1. The first accepted
86
+ // `TaskAccept` from any eligible participant designates that
87
+ // participant as the active assignee. Subsequent `TaskAccept`
88
+ // messages for the same session MUST be rejected if an active
89
+ // assignee is already designated." A conforming runtime rejects the
90
+ // second `TaskAccept` before it reaches accepted history; this guard
91
+ // makes the projection first-accept-wins too, so a rogue second one
92
+ // (e.g. an unfiltered transcript) cannot silently reassign the task.
93
+ //
94
+ // Rule 3c's policy-gated reassignment path (`allow_reassignment_on_reject`,
95
+ // RFC-MACP-0012 `:135`) is deliberately NOT modelled here:
96
+ // `TaskProjection` has no session-policy input today, and rule 3b
97
+ // makes an unconditional reassignment wrong by default ("`TaskAccept`
98
+ // is irrevocable unless policy explicitly permits reassignment").
99
+ // Plumbing policy into this projection needs its own decision (see
100
+ // plans/cross-repo/macp-sdk-typescript-conformance-and-transport.md,
101
+ // Phase 3 open question 4) before that path can be implemented.
102
+ //
103
+ // An anomaly would be recorded when this guard discards a second
104
+ // `TaskAccept`, but `ProjectionAnomalyKind` (`base.ts:8-9`) is
105
+ // deliberately frozen pending cross-SDK agreement with
106
+ // macp-sdk-python.
107
+ if (task && task.assignee === undefined) {
85
108
  task.assignee = record.assignee;
86
109
  task.status = 'accepted';
87
110
  }
@@ -91,6 +114,11 @@ class TaskProjection {
91
114
  case 'TaskReject': {
92
115
  const record = payload;
93
116
  const task = this.tasks.get(record.taskId);
117
+ // Deliberately does not clear `task.assignee`: rule 3c's
118
+ // policy-gated reassignment path is not modelled here (see the
119
+ // `TaskAccept` case above), so `assignee` is treated as sticky once
120
+ // set, per rule 3b's default ("irrevocable unless policy explicitly
121
+ // permits reassignment").
94
122
  if (task)
95
123
  task.status = 'rejected';
96
124
  break;
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.8.0";
7
+ export declare const VERSION = "0.9.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.8.0'; // x-release-please-version
10
+ exports.VERSION = '0.9.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macp-sdk-typescript",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",