macp-sdk-typescript 0.7.1 → 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,100 @@ 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
+
90
+ ## [0.8.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.7.1...v0.8.0) (2026-08-31)
91
+
92
+
93
+ ### ⚠ BREAKING CHANGES
94
+
95
+ * **projections:** DecisionProjection and QuorumProjection now keep the FIRST accepted Vote/ballot per sender (per proposal_id / request_id) and discard later ones, where they previously kept the last. For a transcript containing a genuine duplicate, derived state changes — most consequentially QuorumProjection.hasQuorum(), which could previously be fabricated by a single sender voting twice under requiredApprovals: 1. Conforming transcripts are unaffected. The discarded message is recorded on the new `anomalies` surface.
96
+
97
+ ### Features
98
+
99
+ * **projections:** the first accepted vote or ballot stands ([#61](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/61)) ([ac407c3](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/ac407c3767e785b2a1cf82e1ae735a9ebf1138be))
100
+
7
101
  ## [0.7.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.7.0...v0.7.1) (2026-08-31)
8
102
 
9
103
 
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() {
@@ -88,6 +169,15 @@ class HttpTransportAdapter {
88
169
  if (itemSeq > this.lastSeq) {
89
170
  this.lastSeq = itemSeq;
90
171
  }
172
+ // Normalize message_id -> messageId before yielding: without
173
+ // this, `raw` is the raw snake_case JSON straight off the wire,
174
+ // so `raw.messageId` is `undefined` and the projection's
175
+ // redelivery guard (`if (envelope.messageId)`,
176
+ // `src/projections/base.ts`) short-circuits — every polled
177
+ // envelope looks id-less and skips dedup entirely, leaving HTTP
178
+ // polling silently unprotected against RFC-MACP-0006 §3.2
179
+ // redelivery.
180
+ const messageId = item.message_id ?? item.messageId ?? '';
91
181
  yield {
92
182
  messageType: item.message_type ?? item.messageType ?? '',
93
183
  sender: item.sender ?? '',
@@ -95,7 +185,7 @@ class HttpTransportAdapter {
95
185
  ? item.payload
96
186
  : this.tryParsePayload(item.payload),
97
187
  proposalId: item.proposal_id ?? item.proposalId,
98
- raw: item,
188
+ raw: { ...item, messageId },
99
189
  seq: this.seq++,
100
190
  };
101
191
  }
@@ -1,3 +1,4 @@
1
+ import type { ProjectionAnomaly } from '../projections/base';
1
2
  import type { Envelope } from '../types';
2
3
  export interface IncomingMessage {
3
4
  messageType: string;
@@ -62,6 +63,17 @@ export interface ParticipantLike {
62
63
  export interface ProjectionLike {
63
64
  readonly phase: string;
64
65
  readonly transcript: Envelope[];
66
+ /**
67
+ * Cardinality anomalies observed and discarded by the projection, in transcript order.
68
+ *
69
+ * OPTIONAL, and it must STAY optional. `ProjectionLike` is structurally
70
+ * implementable by consumers — test fakes, custom transports, alternative
71
+ * projection implementations — none of which this SDK controls. Making this
72
+ * member required would break every such implementer at compile time for a
73
+ * field most of them have no reason to provide. The guard below turns a
74
+ * later "tightening" into a build failure rather than a silent break.
75
+ */
76
+ readonly anomalies?: readonly ProjectionAnomaly[];
65
77
  }
66
78
  export type MessageHandler = (event: IncomingMessage, ctx: HandlerContext) => void | Promise<void>;
67
79
  export type TerminalHandler = (result: TerminalResult) => void | Promise<void>;
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
@@ -1,5 +1,53 @@
1
1
  import type { ProtoRegistry } from '../proto-registry';
2
2
  import type { Envelope } from '../types';
3
+ /**
4
+ * The kind of cardinality anomaly a projection recorded while replaying an
5
+ * accepted transcript. Cross-SDK frozen contract (`macp-sdk-python` adopted
6
+ * the identical field set, snake_case there) — do not add a kind without
7
+ * cross-SDK agreement.
8
+ */
9
+ export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot';
10
+ /**
11
+ * A recorded observation that a second distinct `Vote` from this sender for
12
+ * this proposal (RFC-MACP-0007 §5.3), or a second distinct ballot across
13
+ * `Approve`/`Reject`/`Abstain` from this sender for this request
14
+ * (RFC-MACP-0011 §5 rule 3), was observed and discarded — the first stands.
15
+ * (RFC-MACP-0011 §5 rule 3 caps *how many* ballots and is silent on *which of
16
+ * two* stands; first-ballot-wins here is parity with RFC-MACP-0007 §5.3 plus
17
+ * runtime-enforced behaviour.)
18
+ *
19
+ * **Deliberately narrow claim — do not overclaim "this transcript violates
20
+ * the spec".** A projection cannot tell a genuinely non-conforming source
21
+ * from a conforming source fed through an unfiltered loader, because
22
+ * acceptance is not a wire property (`Envelope` carries no acceptance
23
+ * marker — see `applyEnvelope`'s "Input contract: accepted-only" docblock).
24
+ * An anomaly records only what was observed and what this projection did
25
+ * about it (discarded the second one, kept the first); it does not, and
26
+ * structurally cannot, assert that a conforming runtime was actually
27
+ * bypassed. Both `macp-sdk-python` and this SDK state it in these terms —
28
+ * agreed cross-SDK wording, not a style preference.
29
+ *
30
+ * Cross-SDK frozen contract, seven fields (camelCase here, snake_case in
31
+ * `macp-sdk-python`). Do not add, rename, or remove a field without
32
+ * cross-SDK agreement.
33
+ *
34
+ * Only `DecisionProjection` and `QuorumProjection` populate this today.
35
+ */
36
+ export interface ProjectionAnomaly {
37
+ kind: ProjectionAnomalyKind;
38
+ mode: string;
39
+ messageType: string;
40
+ messageId: string;
41
+ sender: string;
42
+ /** `proposal_id` (Decision) or `request_id` (Quorum) the duplicate targeted. */
43
+ subjectId: string;
44
+ /**
45
+ * Human-readable detail. For a cross-type Quorum duplicate, `messageType`
46
+ * is what distinguishes the discarded ballot's type (Approve/Reject/Abstain)
47
+ * from the kept one — `detail` should name both.
48
+ */
49
+ detail: string;
50
+ }
3
51
  /**
4
52
  * Abstract base for in-process mode-state tracking — parity with python-sdk's
5
53
  * `macp_sdk.base_projection.BaseProjection`. Maintains a shared transcript,
@@ -8,13 +56,114 @@ import type { Envelope } from '../types';
8
56
  * handled here so custom modes get the common lifecycle for free.
9
57
  */
10
58
  export declare abstract class BaseProjection {
59
+ /**
60
+ * The session's accepted history as a conforming runtime holds it: one
61
+ * envelope per unique `message_id`, in application order. Redelivered
62
+ * envelopes (same `message_id` — expected under MACP's at-least-once
63
+ * delivery, RFC-MACP-0001 §8, and normatively idempotent per RFC-MACP-0006
64
+ * §3.2 Redelivery) and envelopes for other modes are not appended; a
65
+ * redelivery is logged at debug level and has no effect on projection
66
+ * state.
67
+ */
11
68
  readonly transcript: Envelope[];
12
69
  phase: string;
13
70
  commitment?: Record<string, unknown>;
71
+ /**
72
+ * `message_id`s already applied to this projection, used to make
73
+ * `applyEnvelope` idempotent under redelivery (RFC-MACP-0006 §3.2:
74
+ * "A consumer that accumulates state per envelope ... MUST be idempotent
75
+ * with respect to `message_id`", `:136`). Deliberately unbounded: this
76
+ * class already retains every full envelope (payload bytes included) in
77
+ * `transcript`, so a set of id strings is strictly dominated by that; the
78
+ * runtime keeps an unbounded per-message dedup set itself
79
+ * (`macp-runtime/crates/macp-modes/src/step.rs:48/:89`, field declared at
80
+ * `crates/macp-core/src/session.rs:69`), and sessions are TTL-bounded by
81
+ * protocol.
82
+ */
83
+ private readonly seenMessageIds;
84
+ /**
85
+ * Cardinality anomalies recorded while replaying this projection's
86
+ * accepted transcript (e.g. a duplicate vote or ballot from the same
87
+ * sender). See `ProjectionAnomaly`. Empty unless a subclass calls
88
+ * `recordAnomaly`. NOTE: as of this SDK's built-in modes, nothing calls
89
+ * `recordAnomaly` — `DecisionProjection` and `QuorumProjection` do not
90
+ * extend `BaseProjection` (see the class docblock) and inline their own
91
+ * two lines instead. This field and `recordAnomaly` exist for ext-mode
92
+ * `BaseProjection` subclasses outside this repo.
93
+ */
94
+ readonly anomalies: ProjectionAnomaly[];
14
95
  protected abstract readonly mode: string;
96
+ /**
97
+ * True once at least one `ProjectionAnomaly` has been recorded — i.e. this
98
+ * projection observed and discarded a duplicate vote or ballot from the
99
+ * same sender (see `anomalies` above). This getter is exactly
100
+ * `anomalies.length > 0`; it never asserts "this transcript violates the
101
+ * spec" (see `ProjectionAnomaly`'s docblock, and `applyEnvelope`'s "Input
102
+ * contract: accepted-only" section above, for why a projection cannot make
103
+ * that claim).
104
+ */
105
+ get hasAnomalies(): boolean;
15
106
  get isCommitted(): boolean;
16
107
  get isPositiveOutcome(): boolean | undefined;
108
+ /**
109
+ * Apply one envelope to this projection's in-process state.
110
+ *
111
+ * ## Input contract: accepted-only
112
+ *
113
+ * `applyEnvelope` assumes every envelope it is given was **accepted** by a
114
+ * conforming MACP runtime — i.e. it is part of the session's authoritative
115
+ * accepted history, never a submission the runtime rejected. This is a
116
+ * **caller-maintained invariant**, not something this method can check:
117
+ * `Envelope` (`src/types.ts`) carries no acceptance marker on the wire, so a
118
+ * projection has no field to inspect to tell an accepted envelope from a
119
+ * rejected one. Canonical source, verbatim: `schemas/conformance/README.md`
120
+ * "Notes:" — "SDKs replay only `accept` messages through their projections
121
+ * (reject-path fixtures replay their accepted *prefix*)." The rule this
122
+ * supports is spelled out in RFC-MACP-0007 §5 rule 3 (this repo's shorthand:
123
+ * "§5.3" — the RFC has no literal `### 5.3` heading) — "the first accepted
124
+ * `Vote` stands" — and RFC-MACP-0011 §5 rule 3 — at most one ballot per
125
+ * participant. Both describe what a conforming runtime enforces on ACCEPTED
126
+ * history; a projection reconstructing that history is only correct when fed
127
+ * the same set the runtime held.
128
+ *
129
+ * This SDK's own send paths uphold the invariant already: each of the five
130
+ * built-in mode sessions has its own private `sendAndTrack` that calls
131
+ * `this.projection.applyEnvelope(...)` only `if (ack.ok)`. `BaseSession`'s
132
+ * own `sendAndTrack` (`src/base-session.ts`) — the equivalent path for
133
+ * custom modes built on the ext-mode extension point — does the same. The
134
+ * conformance harness upholds it too: its `acceptedMessages` filter in
135
+ * `tests/conformance/conformance.test.ts` keeps only messages with
136
+ * `expect === 'accept'` before replay.
137
+ *
138
+ * **Failure mode if the contract is violated:** wiring this method to raw
139
+ * captured or hand-built traffic — including envelopes a runtime rejected —
140
+ * replays a timeline that no conforming runtime ever held, fabricating
141
+ * projection state. Concretely: a session whose only `Commitment` was
142
+ * rejected can still end up with `isCommitted === true` and
143
+ * `phase === 'Committed'` if that rejected envelope is replayed anyway. See
144
+ * `tests/unit/projections/accepted-only-contract.test.ts` for an executable
145
+ * demonstration, both with the corruption present and with it avoided.
146
+ *
147
+ * @param envelope - MUST be an envelope the runtime accepted; the caller is
148
+ * responsible for filtering out rejected submissions before calling this
149
+ * method. Envelopes for a different `mode` are ignored (the `if
150
+ * (envelope.mode !== this.mode) return;` guard below, in this method's own
151
+ * body), but that check is unrelated to this contract and does not
152
+ * substitute for it.
153
+ */
17
154
  applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
18
155
  /** Handle a mode-specific (non-Commitment) envelope. */
19
156
  protected abstract applyMode(envelope: Envelope, protoRegistry: ProtoRegistry): void;
157
+ /**
158
+ * Record a cardinality anomaly: push it onto `anomalies` and emit it via
159
+ * `logger.warn('projection anomaly', anomaly)`. The `anomalies` array is
160
+ * the canonical, cross-SDK-agreed semantic; the log call is explicitly
161
+ * NON-contractual observability and may differ per SDK. `logger.warn` is
162
+ * visible by default (this SDK's default log level is `warn`,
163
+ * `src/logging.ts`) — observing and discarding a duplicate is deliberately
164
+ * not silent. To quiet it without losing the `anomalies` array, configure a
165
+ * higher level: `configureLogging({ level: 'error' })` or the
166
+ * `MACP_LOG_LEVEL` environment variable.
167
+ */
168
+ protected recordAnomaly(anomaly: ProjectionAnomaly): void;
20
169
  }