macp-sdk-typescript 0.7.0 → 0.8.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,24 @@ 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.8.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.7.1...v0.8.0) (2026-08-31)
8
+
9
+
10
+ ### ⚠ BREAKING CHANGES
11
+
12
+ * **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.
13
+
14
+ ### Features
15
+
16
+ * **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))
17
+
18
+ ## [0.7.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.7.0...v0.7.1) (2026-08-31)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * validate explicit empty-string sessionId, and freeze the commitment field set ([#52](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/52)) ([5e4dc25](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/commit/5e4dc25389d948570afbd9653cfd52bc12e97150)), closes [#47](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/47) [#48](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/issues/48)
24
+
7
25
  ## [0.7.0](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.6.0...v0.7.0) (2026-08-30)
8
26
 
9
27
 
@@ -88,6 +88,15 @@ class HttpTransportAdapter {
88
88
  if (itemSeq > this.lastSeq) {
89
89
  this.lastSeq = itemSeq;
90
90
  }
91
+ // Normalize message_id -> messageId before yielding: without
92
+ // this, `raw` is the raw snake_case JSON straight off the wire,
93
+ // so `raw.messageId` is `undefined` and the projection's
94
+ // redelivery guard (`if (envelope.messageId)`,
95
+ // `src/projections/base.ts`) short-circuits — every polled
96
+ // envelope looks id-less and skips dedup entirely, leaving HTTP
97
+ // polling silently unprotected against RFC-MACP-0006 §3.2
98
+ // redelivery.
99
+ const messageId = item.message_id ?? item.messageId ?? '';
91
100
  yield {
92
101
  messageType: item.message_type ?? item.messageType ?? '',
93
102
  sender: item.sender ?? '',
@@ -95,7 +104,7 @@ class HttpTransportAdapter {
95
104
  ? item.payload
96
105
  : this.tryParsePayload(item.payload),
97
106
  proposalId: item.proposal_id ?? item.proposalId,
98
- raw: item,
107
+ raw: { ...item, messageId },
99
108
  seq: this.seq++,
100
109
  };
101
110
  }
@@ -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>;
@@ -26,7 +26,7 @@ class BaseSession {
26
26
  projection;
27
27
  constructor(client, options = {}) {
28
28
  this.client = client;
29
- if (options.sessionId)
29
+ if (options.sessionId !== undefined)
30
30
  (0, validation_1.validateSessionId)(options.sessionId);
31
31
  this.sessionId = options.sessionId ?? (0, envelope_1.newSessionId)();
32
32
  this.modeVersion = options.modeVersion ?? constants_1.DEFAULT_MODE_VERSION;
package/dist/decision.js CHANGED
@@ -16,7 +16,7 @@ class DecisionSession {
16
16
  projection = new projections_1.DecisionProjection();
17
17
  constructor(client, options = {}) {
18
18
  this.client = client;
19
- if (options.sessionId)
19
+ if (options.sessionId !== undefined)
20
20
  (0, validation_1.validateSessionId)(options.sessionId);
21
21
  this.sessionId = options.sessionId ?? (0, envelope_1.newSessionId)();
22
22
  this.modeVersion = options.modeVersion ?? constants_1.DEFAULT_MODE_VERSION;
package/dist/handoff.js CHANGED
@@ -16,7 +16,7 @@ class HandoffSession {
16
16
  projection = new handoff_1.HandoffProjection();
17
17
  constructor(client, options = {}) {
18
18
  this.client = client;
19
- if (options.sessionId)
19
+ if (options.sessionId !== undefined)
20
20
  (0, validation_1.validateSessionId)(options.sessionId);
21
21
  this.sessionId = options.sessionId ?? (0, envelope_1.newSessionId)();
22
22
  this.modeVersion = options.modeVersion ?? constants_1.DEFAULT_MODE_VERSION;
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BaseProjection = void 0;
4
+ const logging_1 = require("../logging");
4
5
  /**
5
6
  * Abstract base for in-process mode-state tracking — parity with python-sdk's
6
7
  * `macp_sdk.base_projection.BaseProjection`. Maintains a shared transcript,
@@ -9,9 +10,54 @@ exports.BaseProjection = void 0;
9
10
  * handled here so custom modes get the common lifecycle for free.
10
11
  */
11
12
  class BaseProjection {
13
+ /**
14
+ * The session's accepted history as a conforming runtime holds it: one
15
+ * envelope per unique `message_id`, in application order. Redelivered
16
+ * envelopes (same `message_id` — expected under MACP's at-least-once
17
+ * delivery, RFC-MACP-0001 §8, and normatively idempotent per RFC-MACP-0006
18
+ * §3.2 Redelivery) and envelopes for other modes are not appended; a
19
+ * redelivery is logged at debug level and has no effect on projection
20
+ * state.
21
+ */
12
22
  transcript = [];
13
23
  phase = '';
14
24
  commitment;
25
+ /**
26
+ * `message_id`s already applied to this projection, used to make
27
+ * `applyEnvelope` idempotent under redelivery (RFC-MACP-0006 §3.2:
28
+ * "A consumer that accumulates state per envelope ... MUST be idempotent
29
+ * with respect to `message_id`", `:136`). Deliberately unbounded: this
30
+ * class already retains every full envelope (payload bytes included) in
31
+ * `transcript`, so a set of id strings is strictly dominated by that; the
32
+ * runtime keeps an unbounded per-message dedup set itself
33
+ * (`macp-runtime/crates/macp-modes/src/step.rs:48/:89`, field declared at
34
+ * `crates/macp-core/src/session.rs:69`), and sessions are TTL-bounded by
35
+ * protocol.
36
+ */
37
+ seenMessageIds = new Set();
38
+ /**
39
+ * Cardinality anomalies recorded while replaying this projection's
40
+ * accepted transcript (e.g. a duplicate vote or ballot from the same
41
+ * sender). See `ProjectionAnomaly`. Empty unless a subclass calls
42
+ * `recordAnomaly`. NOTE: as of this SDK's built-in modes, nothing calls
43
+ * `recordAnomaly` — `DecisionProjection` and `QuorumProjection` do not
44
+ * extend `BaseProjection` (see the class docblock) and inline their own
45
+ * two lines instead. This field and `recordAnomaly` exist for ext-mode
46
+ * `BaseProjection` subclasses outside this repo.
47
+ */
48
+ anomalies = [];
49
+ /**
50
+ * True once at least one `ProjectionAnomaly` has been recorded — i.e. this
51
+ * projection observed and discarded a duplicate vote or ballot from the
52
+ * same sender (see `anomalies` above). This getter is exactly
53
+ * `anomalies.length > 0`; it never asserts "this transcript violates the
54
+ * spec" (see `ProjectionAnomaly`'s docblock, and `applyEnvelope`'s "Input
55
+ * contract: accepted-only" section above, for why a projection cannot make
56
+ * that claim).
57
+ */
58
+ get hasAnomalies() {
59
+ return this.anomalies.length > 0;
60
+ }
15
61
  get isCommitted() {
16
62
  return this.commitment !== undefined;
17
63
  }
@@ -22,9 +68,74 @@ class BaseProjection {
22
68
  this.commitment.outcome_positive;
23
69
  return val !== undefined ? Boolean(val) : true;
24
70
  }
71
+ /**
72
+ * Apply one envelope to this projection's in-process state.
73
+ *
74
+ * ## Input contract: accepted-only
75
+ *
76
+ * `applyEnvelope` assumes every envelope it is given was **accepted** by a
77
+ * conforming MACP runtime — i.e. it is part of the session's authoritative
78
+ * accepted history, never a submission the runtime rejected. This is a
79
+ * **caller-maintained invariant**, not something this method can check:
80
+ * `Envelope` (`src/types.ts`) carries no acceptance marker on the wire, so a
81
+ * projection has no field to inspect to tell an accepted envelope from a
82
+ * rejected one. Canonical source, verbatim: `schemas/conformance/README.md`
83
+ * "Notes:" — "SDKs replay only `accept` messages through their projections
84
+ * (reject-path fixtures replay their accepted *prefix*)." The rule this
85
+ * supports is spelled out in RFC-MACP-0007 §5 rule 3 (this repo's shorthand:
86
+ * "§5.3" — the RFC has no literal `### 5.3` heading) — "the first accepted
87
+ * `Vote` stands" — and RFC-MACP-0011 §5 rule 3 — at most one ballot per
88
+ * participant. Both describe what a conforming runtime enforces on ACCEPTED
89
+ * history; a projection reconstructing that history is only correct when fed
90
+ * the same set the runtime held.
91
+ *
92
+ * This SDK's own send paths uphold the invariant already: each of the five
93
+ * built-in mode sessions has its own private `sendAndTrack` that calls
94
+ * `this.projection.applyEnvelope(...)` only `if (ack.ok)`. `BaseSession`'s
95
+ * own `sendAndTrack` (`src/base-session.ts`) — the equivalent path for
96
+ * custom modes built on the ext-mode extension point — does the same. The
97
+ * conformance harness upholds it too: its `acceptedMessages` filter in
98
+ * `tests/conformance/conformance.test.ts` keeps only messages with
99
+ * `expect === 'accept'` before replay.
100
+ *
101
+ * **Failure mode if the contract is violated:** wiring this method to raw
102
+ * captured or hand-built traffic — including envelopes a runtime rejected —
103
+ * replays a timeline that no conforming runtime ever held, fabricating
104
+ * projection state. Concretely: a session whose only `Commitment` was
105
+ * rejected can still end up with `isCommitted === true` and
106
+ * `phase === 'Committed'` if that rejected envelope is replayed anyway. See
107
+ * `tests/unit/projections/accepted-only-contract.test.ts` for an executable
108
+ * demonstration, both with the corruption present and with it avoided.
109
+ *
110
+ * @param envelope - MUST be an envelope the runtime accepted; the caller is
111
+ * responsible for filtering out rejected submissions before calling this
112
+ * method. Envelopes for a different `mode` are ignored (the `if
113
+ * (envelope.mode !== this.mode) return;` guard below, in this method's own
114
+ * body), but that check is unrelated to this contract and does not
115
+ * substitute for it.
116
+ */
25
117
  applyEnvelope(envelope, protoRegistry) {
26
118
  if (envelope.mode !== this.mode)
27
119
  return;
120
+ // RFC-MACP-0006 §3.2 Redelivery (`:94`, `:134`-`:136`): a runtime MAY echo
121
+ // back accepted envelopes, a redelivery MUST NOT advance sequence position
122
+ // or count a second time against cardinality, and a consumer that
123
+ // accumulates state per envelope MUST be idempotent w.r.t. `message_id`.
124
+ // The `if (envelope.messageId)` guard is mandatory: an empty/absent id
125
+ // must apply WITHOUT dedup, or a feed of empty-id envelopes collapses to
126
+ // one. This is architecturally expected (RFC-MACP-0001:306 at-least-once
127
+ // delivery), not an anomaly — hence `debug`, never `warn`.
128
+ if (envelope.messageId) {
129
+ if (this.seenMessageIds.has(envelope.messageId)) {
130
+ logging_1.logger.debug('projection redelivery ignored', {
131
+ messageId: envelope.messageId,
132
+ mode: envelope.mode,
133
+ messageType: envelope.messageType,
134
+ });
135
+ return;
136
+ }
137
+ this.seenMessageIds.add(envelope.messageId);
138
+ }
28
139
  this.transcript.push(envelope);
29
140
  if (envelope.messageType === 'Commitment') {
30
141
  this.commitment = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
@@ -33,5 +144,20 @@ class BaseProjection {
33
144
  }
34
145
  this.applyMode(envelope, protoRegistry);
35
146
  }
147
+ /**
148
+ * Record a cardinality anomaly: push it onto `anomalies` and emit it via
149
+ * `logger.warn('projection anomaly', anomaly)`. The `anomalies` array is
150
+ * the canonical, cross-SDK-agreed semantic; the log call is explicitly
151
+ * NON-contractual observability and may differ per SDK. `logger.warn` is
152
+ * visible by default (this SDK's default log level is `warn`,
153
+ * `src/logging.ts`) — observing and discarding a duplicate is deliberately
154
+ * not silent. To quiet it without losing the `anomalies` array, configure a
155
+ * higher level: `configureLogging({ level: 'error' })` or the
156
+ * `MACP_LOG_LEVEL` environment variable.
157
+ */
158
+ recordAnomaly(anomaly) {
159
+ this.anomalies.push(anomaly);
160
+ logging_1.logger.warn('projection anomaly', anomaly);
161
+ }
36
162
  }
37
163
  exports.BaseProjection = BaseProjection;
@@ -1,3 +1,4 @@
1
+ import type { ProjectionAnomaly } from './base';
1
2
  import type { Envelope } from '../types';
2
3
  import type { ProtoRegistry } from '../proto-registry';
3
4
  export interface DecisionProposalRecord {
@@ -30,10 +31,52 @@ export declare class DecisionProjection {
30
31
  readonly evaluations: DecisionEvaluationRecord[];
31
32
  readonly objections: DecisionObjectionRecord[];
32
33
  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
+ */
33
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[];
34
49
  phase: 'Proposal' | 'Evaluation' | 'Voting' | 'Committed';
35
50
  commitment?: Record<string, unknown>;
51
+ /**
52
+ * `message_id`s already applied to this projection. See
53
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
54
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
55
+ * only as a one-line pointer.
56
+ */
57
+ private readonly seenMessageIds;
58
+ /**
59
+ * Apply one envelope to this projection's in-process state.
60
+ *
61
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
62
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
63
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
64
+ * mode are documented once, on `BaseProjection.applyEnvelope`
65
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
66
+ * pointer so six independent copies of the same prose cannot drift.
67
+ *
68
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
69
+ * non-event — not appended to `transcript`, not passed to the `switch`.
70
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
71
+ * citation; duplicated here only as a one-line pointer.
72
+ */
36
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;
37
80
  get isCommitted(): boolean;
38
81
  get isPositiveOutcome(): boolean | undefined;
39
82
  voteTotals(): Record<string, number>;
@@ -2,17 +2,65 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DecisionProjection = void 0;
4
4
  const constants_1 = require("../constants");
5
+ const logging_1 = require("../logging");
5
6
  class DecisionProjection {
6
7
  proposals = new Map();
7
8
  evaluations = [];
8
9
  objections = [];
9
10
  votes = new Map();
11
+ /**
12
+ * The session's accepted history, one envelope per unique `message_id`. See
13
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
14
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
15
+ * only as a one-line pointer.
16
+ */
10
17
  transcript = [];
18
+ /**
19
+ * Cardinality anomalies recorded while replaying this projection's accepted
20
+ * transcript (e.g. a duplicate `Vote` from the same sender for the same
21
+ * `proposal_id`). See `BaseProjection.anomalies` (`src/projections/base.ts`)
22
+ * for the canonical description; duplicated here only as a one-line
23
+ * pointer.
24
+ */
25
+ anomalies = [];
11
26
  phase = 'Proposal';
12
27
  commitment;
28
+ /**
29
+ * `message_id`s already applied to this projection. See
30
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
31
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
32
+ * only as a one-line pointer.
33
+ */
34
+ seenMessageIds = new Set();
35
+ /**
36
+ * Apply one envelope to this projection's in-process state.
37
+ *
38
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
39
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
40
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
41
+ * mode are documented once, on `BaseProjection.applyEnvelope`
42
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
43
+ * pointer so six independent copies of the same prose cannot drift.
44
+ *
45
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
46
+ * non-event — not appended to `transcript`, not passed to the `switch`.
47
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
48
+ * citation; duplicated here only as a one-line pointer.
49
+ */
13
50
  applyEnvelope(envelope, protoRegistry) {
14
51
  if (envelope.mode !== constants_1.MODE_DECISION)
15
52
  return;
53
+ if (envelope.messageId) {
54
+ if (this.seenMessageIds.has(envelope.messageId)) {
55
+ logging_1.logger.debug('projection redelivery ignored', {
56
+ messageId: envelope.messageId,
57
+ mode: envelope.mode,
58
+ messageType: envelope.messageType,
59
+ });
60
+ return;
61
+ }
62
+ this.seenMessageIds.add(envelope.messageId);
63
+ }
16
64
  this.transcript.push(envelope);
17
65
  const payload = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
18
66
  switch (envelope.messageType) {
@@ -40,6 +88,25 @@ class DecisionProjection {
40
88
  case 'Vote': {
41
89
  const record = payload;
42
90
  const bySender = this.votes.get(record.proposalId) ?? new Map();
91
+ const kept = bySender.get(envelope.sender);
92
+ if (kept !== undefined) {
93
+ // RFC-MACP-0007 §5 item 3: the first accepted Vote stands. A conforming
94
+ // runtime NACKs the duplicate (macp-runtime decision.rs), so reaching
95
+ // here means the transcript was not filtered to a conforming
96
+ // runtime's accepted history.
97
+ const anomaly = {
98
+ kind: 'duplicate_vote',
99
+ mode: envelope.mode,
100
+ messageType: envelope.messageType,
101
+ messageId: envelope.messageId,
102
+ sender: envelope.sender,
103
+ subjectId: record.proposalId,
104
+ detail: `sender ${envelope.sender} already voted '${kept.vote}' on proposal ${record.proposalId}; discarded '${record.vote}'`,
105
+ };
106
+ this.anomalies.push(anomaly);
107
+ logging_1.logger.warn('projection anomaly', anomaly);
108
+ break;
109
+ }
43
110
  bySender.set(envelope.sender, { ...record, sender: envelope.sender });
44
111
  this.votes.set(record.proposalId, bySender);
45
112
  this.phase = 'Voting';
@@ -54,6 +121,14 @@ class DecisionProjection {
54
121
  break;
55
122
  }
56
123
  }
124
+ /**
125
+ * True once at least one `ProjectionAnomaly` has been recorded. See
126
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
127
+ * canonical description; duplicated here only as a one-line pointer.
128
+ */
129
+ get hasAnomalies() {
130
+ return this.anomalies.length > 0;
131
+ }
57
132
  get isCommitted() {
58
133
  return this.commitment !== undefined;
59
134
  }
@@ -1,3 +1,4 @@
1
+ import type { ProjectionAnomaly } from './base';
1
2
  import type { Envelope } from '../types';
2
3
  import type { ProtoRegistry } from '../proto-registry';
3
4
  export interface HandoffRecord {
@@ -19,10 +20,51 @@ export interface HandoffRecord {
19
20
  }
20
21
  export declare class HandoffProjection {
21
22
  readonly handoffs: Map<string, HandoffRecord>;
23
+ /**
24
+ * The session's accepted history, one envelope per unique `message_id`. See
25
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
26
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
27
+ * only as a one-line pointer.
28
+ */
22
29
  readonly transcript: Envelope[];
30
+ /**
31
+ * Cardinality anomalies recorded while replaying this projection's accepted
32
+ * transcript. See `BaseProjection.anomalies` (`src/projections/base.ts`)
33
+ * for the canonical description; duplicated here only as a one-line
34
+ * pointer. No built-in detection populates this for Handoff mode.
35
+ */
36
+ readonly anomalies: ProjectionAnomaly[];
23
37
  phase: 'Pending' | 'OfferPending' | 'ContextSharing' | 'Accepted' | 'Declined' | 'Committed';
24
38
  commitment?: Record<string, unknown>;
39
+ /**
40
+ * `message_id`s already applied to this projection. See
41
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
42
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
43
+ * only as a one-line pointer.
44
+ */
45
+ private readonly seenMessageIds;
46
+ /**
47
+ * Apply one envelope to this projection's in-process state.
48
+ *
49
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
50
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
51
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
52
+ * mode are documented once, on `BaseProjection.applyEnvelope`
53
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
54
+ * pointer so six independent copies of the same prose cannot drift.
55
+ *
56
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
57
+ * non-event — not appended to `transcript`, not passed to the `switch`.
58
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
59
+ * citation; duplicated here only as a one-line pointer.
60
+ */
25
61
  applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
62
+ /**
63
+ * True once at least one `ProjectionAnomaly` has been recorded. See
64
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
65
+ * canonical description; duplicated here only as a one-line pointer.
66
+ */
67
+ get hasAnomalies(): boolean;
26
68
  get isCommitted(): boolean;
27
69
  get isPositiveOutcome(): boolean | undefined;
28
70
  getHandoff(handoffId: string): HandoffRecord | undefined;