macp-sdk-typescript 0.7.1 → 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,17 @@ 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
+
7
18
  ## [0.7.1](https://github.com/multiagentcoordinationprotocol/macp-sdk-typescript/compare/v0.7.0...v0.7.1) (2026-08-31)
8
19
 
9
20
 
@@ -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>;
@@ -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;
@@ -2,14 +2,61 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HandoffProjection = void 0;
4
4
  const constants_1 = require("../constants");
5
+ const logging_1 = require("../logging");
5
6
  class HandoffProjection {
6
7
  handoffs = new Map();
8
+ /**
9
+ * The session's accepted history, one envelope per unique `message_id`. See
10
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
11
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
12
+ * only as a one-line pointer.
13
+ */
7
14
  transcript = [];
15
+ /**
16
+ * Cardinality anomalies recorded while replaying this projection's accepted
17
+ * transcript. See `BaseProjection.anomalies` (`src/projections/base.ts`)
18
+ * for the canonical description; duplicated here only as a one-line
19
+ * pointer. No built-in detection populates this for Handoff mode.
20
+ */
21
+ anomalies = [];
8
22
  phase = 'Pending';
9
23
  commitment;
24
+ /**
25
+ * `message_id`s already applied to this projection. See
26
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
27
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
28
+ * only as a one-line pointer.
29
+ */
30
+ seenMessageIds = new Set();
31
+ /**
32
+ * Apply one envelope to this projection's in-process state.
33
+ *
34
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
35
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
36
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
37
+ * mode are documented once, on `BaseProjection.applyEnvelope`
38
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
39
+ * pointer so six independent copies of the same prose cannot drift.
40
+ *
41
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
42
+ * non-event — not appended to `transcript`, not passed to the `switch`.
43
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
44
+ * citation; duplicated here only as a one-line pointer.
45
+ */
10
46
  applyEnvelope(envelope, protoRegistry) {
11
47
  if (envelope.mode !== constants_1.MODE_HANDOFF)
12
48
  return;
49
+ if (envelope.messageId) {
50
+ if (this.seenMessageIds.has(envelope.messageId)) {
51
+ logging_1.logger.debug('projection redelivery ignored', {
52
+ messageId: envelope.messageId,
53
+ mode: envelope.mode,
54
+ messageType: envelope.messageType,
55
+ });
56
+ return;
57
+ }
58
+ this.seenMessageIds.add(envelope.messageId);
59
+ }
13
60
  this.transcript.push(envelope);
14
61
  const payload = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
15
62
  switch (envelope.messageType) {
@@ -74,6 +121,14 @@ class HandoffProjection {
74
121
  break;
75
122
  }
76
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
+ }
77
132
  get isCommitted() {
78
133
  return this.commitment !== undefined;
79
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 ProposalRecord {
@@ -24,10 +25,51 @@ export declare class ProposalProjection {
24
25
  readonly proposals: Map<string, ProposalRecord>;
25
26
  readonly accepts: ProposalAcceptRecord[];
26
27
  readonly rejections: ProposalRejectRecord[];
28
+ /**
29
+ * The session's accepted history, one envelope per unique `message_id`. See
30
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
31
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
32
+ * only as a one-line pointer.
33
+ */
27
34
  readonly transcript: Envelope[];
35
+ /**
36
+ * Cardinality anomalies recorded while replaying this projection's accepted
37
+ * transcript. See `BaseProjection.anomalies` (`src/projections/base.ts`)
38
+ * for the canonical description; duplicated here only as a one-line
39
+ * pointer. No built-in detection populates this for Proposal mode.
40
+ */
41
+ readonly anomalies: ProjectionAnomaly[];
28
42
  phase: 'Negotiating' | 'TerminalRejected' | 'Committed';
29
43
  commitment?: Record<string, unknown>;
44
+ /**
45
+ * `message_id`s already applied to this projection. See
46
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
47
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
48
+ * only as a one-line pointer.
49
+ */
50
+ private readonly seenMessageIds;
51
+ /**
52
+ * Apply one envelope to this projection's in-process state.
53
+ *
54
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
55
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
56
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
57
+ * mode are documented once, on `BaseProjection.applyEnvelope`
58
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
59
+ * pointer so six independent copies of the same prose cannot drift.
60
+ *
61
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
62
+ * non-event — not appended to `transcript`, not passed to the `switch`.
63
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
64
+ * citation; duplicated here only as a one-line pointer.
65
+ */
30
66
  applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
67
+ /**
68
+ * True once at least one `ProjectionAnomaly` has been recorded. See
69
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
70
+ * canonical description; duplicated here only as a one-line pointer.
71
+ */
72
+ get hasAnomalies(): boolean;
31
73
  get isCommitted(): boolean;
32
74
  get isPositiveOutcome(): boolean | undefined;
33
75
  activeProposals(): ProposalRecord[];
@@ -2,16 +2,63 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProposalProjection = void 0;
4
4
  const constants_1 = require("../constants");
5
+ const logging_1 = require("../logging");
5
6
  class ProposalProjection {
6
7
  proposals = new Map();
7
8
  accepts = [];
8
9
  rejections = [];
10
+ /**
11
+ * The session's accepted history, one envelope per unique `message_id`. See
12
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
13
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
14
+ * only as a one-line pointer.
15
+ */
9
16
  transcript = [];
17
+ /**
18
+ * Cardinality anomalies recorded while replaying this projection's accepted
19
+ * transcript. See `BaseProjection.anomalies` (`src/projections/base.ts`)
20
+ * for the canonical description; duplicated here only as a one-line
21
+ * pointer. No built-in detection populates this for Proposal mode.
22
+ */
23
+ anomalies = [];
10
24
  phase = 'Negotiating';
11
25
  commitment;
26
+ /**
27
+ * `message_id`s already applied to this projection. See
28
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
29
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
30
+ * only as a one-line pointer.
31
+ */
32
+ seenMessageIds = new Set();
33
+ /**
34
+ * Apply one envelope to this projection's in-process state.
35
+ *
36
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
37
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
38
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
39
+ * mode are documented once, on `BaseProjection.applyEnvelope`
40
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
41
+ * pointer so six independent copies of the same prose cannot drift.
42
+ *
43
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
44
+ * non-event — not appended to `transcript`, not passed to the `switch`.
45
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
46
+ * citation; duplicated here only as a one-line pointer.
47
+ */
12
48
  applyEnvelope(envelope, protoRegistry) {
13
49
  if (envelope.mode !== constants_1.MODE_PROPOSAL)
14
50
  return;
51
+ if (envelope.messageId) {
52
+ if (this.seenMessageIds.has(envelope.messageId)) {
53
+ logging_1.logger.debug('projection redelivery ignored', {
54
+ messageId: envelope.messageId,
55
+ mode: envelope.mode,
56
+ messageType: envelope.messageType,
57
+ });
58
+ return;
59
+ }
60
+ this.seenMessageIds.add(envelope.messageId);
61
+ }
15
62
  this.transcript.push(envelope);
16
63
  const payload = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
17
64
  switch (envelope.messageType) {
@@ -77,6 +124,14 @@ class ProposalProjection {
77
124
  break;
78
125
  }
79
126
  }
127
+ /**
128
+ * True once at least one `ProjectionAnomaly` has been recorded. See
129
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
130
+ * canonical description; duplicated here only as a one-line pointer.
131
+ */
132
+ get hasAnomalies() {
133
+ return this.anomalies.length > 0;
134
+ }
80
135
  get isCommitted() {
81
136
  return this.commitment !== undefined;
82
137
  }
@@ -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 ApprovalRequestRecord {
@@ -16,10 +17,52 @@ export interface BallotRecord {
16
17
  export declare class QuorumProjection {
17
18
  readonly requests: Map<string, ApprovalRequestRecord>;
18
19
  readonly ballots: Map<string, Map<string, BallotRecord>>;
20
+ /**
21
+ * The session's accepted history, one envelope per unique `message_id`. See
22
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
23
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
24
+ * only as a one-line pointer.
25
+ */
19
26
  readonly transcript: Envelope[];
27
+ /**
28
+ * Cardinality anomalies recorded while replaying this projection's accepted
29
+ * transcript (e.g. a duplicate ballot across `Approve`/`Reject`/`Abstain`
30
+ * from the same sender for the same `request_id`). See
31
+ * `BaseProjection.anomalies` (`src/projections/base.ts`) for the canonical
32
+ * description; duplicated here only as a one-line pointer.
33
+ */
34
+ readonly anomalies: ProjectionAnomaly[];
20
35
  phase: 'Pending' | 'Voting' | 'Committed';
21
36
  commitment?: Record<string, unknown>;
37
+ /**
38
+ * `message_id`s already applied to this projection. See
39
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
40
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
41
+ * only as a one-line pointer.
42
+ */
43
+ private readonly seenMessageIds;
44
+ /**
45
+ * Apply one envelope to this projection's in-process state.
46
+ *
47
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
48
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
49
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
50
+ * mode are documented once, on `BaseProjection.applyEnvelope`
51
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
52
+ * pointer so six independent copies of the same prose cannot drift.
53
+ *
54
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
55
+ * non-event — not appended to `transcript`, not passed to the `switch`.
56
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
57
+ * citation; duplicated here only as a one-line pointer.
58
+ */
22
59
  applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
60
+ /**
61
+ * True once at least one `ProjectionAnomaly` has been recorded. See
62
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
63
+ * canonical description; duplicated here only as a one-line pointer.
64
+ */
65
+ get hasAnomalies(): boolean;
23
66
  get isCommitted(): boolean;
24
67
  get isPositiveOutcome(): boolean | undefined;
25
68
  private setBallot;
@@ -2,15 +2,63 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.QuorumProjection = void 0;
4
4
  const constants_1 = require("../constants");
5
+ const logging_1 = require("../logging");
5
6
  class QuorumProjection {
6
7
  requests = new Map();
7
8
  ballots = new Map();
9
+ /**
10
+ * The session's accepted history, one envelope per unique `message_id`. See
11
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
12
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
13
+ * only as a one-line pointer.
14
+ */
8
15
  transcript = [];
16
+ /**
17
+ * Cardinality anomalies recorded while replaying this projection's accepted
18
+ * transcript (e.g. a duplicate ballot across `Approve`/`Reject`/`Abstain`
19
+ * from the same sender for the same `request_id`). See
20
+ * `BaseProjection.anomalies` (`src/projections/base.ts`) for the canonical
21
+ * description; duplicated here only as a one-line pointer.
22
+ */
23
+ anomalies = [];
9
24
  phase = 'Pending';
10
25
  commitment;
26
+ /**
27
+ * `message_id`s already applied to this projection. See
28
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
29
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
30
+ * only as a one-line pointer.
31
+ */
32
+ seenMessageIds = new Set();
33
+ /**
34
+ * Apply one envelope to this projection's in-process state.
35
+ *
36
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
37
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
38
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
39
+ * mode are documented once, on `BaseProjection.applyEnvelope`
40
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
41
+ * pointer so six independent copies of the same prose cannot drift.
42
+ *
43
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
44
+ * non-event — not appended to `transcript`, not passed to the `switch`.
45
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
46
+ * citation; duplicated here only as a one-line pointer.
47
+ */
11
48
  applyEnvelope(envelope, protoRegistry) {
12
49
  if (envelope.mode !== constants_1.MODE_QUORUM)
13
50
  return;
51
+ if (envelope.messageId) {
52
+ if (this.seenMessageIds.has(envelope.messageId)) {
53
+ logging_1.logger.debug('projection redelivery ignored', {
54
+ messageId: envelope.messageId,
55
+ mode: envelope.mode,
56
+ messageType: envelope.messageType,
57
+ });
58
+ return;
59
+ }
60
+ this.seenMessageIds.add(envelope.messageId);
61
+ }
14
62
  this.transcript.push(envelope);
15
63
  const payload = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
16
64
  switch (envelope.messageType) {
@@ -22,17 +70,17 @@ class QuorumProjection {
22
70
  }
23
71
  case 'Approve': {
24
72
  const record = payload;
25
- this.setBallot(record.requestId, envelope.sender, 'approve', record.reason);
73
+ this.setBallot(envelope, record.requestId, 'approve', record.reason);
26
74
  break;
27
75
  }
28
76
  case 'Reject': {
29
77
  const record = payload;
30
- this.setBallot(record.requestId, envelope.sender, 'reject', record.reason);
78
+ this.setBallot(envelope, record.requestId, 'reject', record.reason);
31
79
  break;
32
80
  }
33
81
  case 'Abstain': {
34
82
  const record = payload;
35
- this.setBallot(record.requestId, envelope.sender, 'abstain', record.reason);
83
+ this.setBallot(envelope, record.requestId, 'abstain', record.reason);
36
84
  break;
37
85
  }
38
86
  case 'Commitment': {
@@ -44,6 +92,14 @@ class QuorumProjection {
44
92
  break;
45
93
  }
46
94
  }
95
+ /**
96
+ * True once at least one `ProjectionAnomaly` has been recorded. See
97
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
98
+ * canonical description; duplicated here only as a one-line pointer.
99
+ */
100
+ get hasAnomalies() {
101
+ return this.anomalies.length > 0;
102
+ }
47
103
  get isCommitted() {
48
104
  return this.commitment !== undefined;
49
105
  }
@@ -54,9 +110,39 @@ class QuorumProjection {
54
110
  this.commitment.outcome_positive;
55
111
  return val !== undefined ? Boolean(val) : true;
56
112
  }
57
- setBallot(requestId, sender, vote, reason) {
113
+ setBallot(envelope, requestId, vote, reason) {
58
114
  const senderMap = this.ballots.get(requestId) ?? new Map();
59
- senderMap.set(sender, { requestId, vote, reason, sender });
115
+ const kept = senderMap.get(envelope.sender);
116
+ if (kept !== undefined) {
117
+ // RFC-MACP-0011 §5 rule 3 (`:67`): participation is MAY (a participant need
118
+ // not cast a ballot), but the cap of one ballot per participant ACROSS
119
+ // Approve, Reject, and Abstain is enforced under §5's opening sentence,
120
+ // "Implementations MUST enforce the following:" (`:63`). Do NOT phrase this
121
+ // as "a participant MUST cast at most one ballot" — RFC-0011 puts the MUST
122
+ // on the implementation, not the participant (contrast RFC-MACP-0007 §5.3,
123
+ // which puts it directly on the participant; same obligation, different
124
+ // addressee). A later ballot of a *different* type is still a duplicate,
125
+ // not a change of vote, so this guard is keyed on the sender ALONE within
126
+ // the request — never on sender + vote. macp-runtime enforces this
127
+ // identically in all three arms (quorum.rs:164/184/204).
128
+ // Keeping the sender's FIRST ballot (rather than the last) is not stated
129
+ // by RFC-0011 either; it is parity with RFC-MACP-0007 §5 item 3's
130
+ // explicit first-stands rule for `Vote`, plus macp-runtime's enforced
131
+ // first-wins behaviour.
132
+ const anomaly = {
133
+ kind: 'duplicate_ballot',
134
+ mode: envelope.mode,
135
+ messageType: envelope.messageType,
136
+ messageId: envelope.messageId,
137
+ sender: envelope.sender,
138
+ subjectId: requestId,
139
+ detail: `sender ${envelope.sender} already cast '${kept.vote}' on request ${requestId}; discarded '${vote}'`,
140
+ };
141
+ this.anomalies.push(anomaly);
142
+ logging_1.logger.warn('projection anomaly', anomaly);
143
+ return;
144
+ }
145
+ senderMap.set(envelope.sender, { requestId, vote, reason, sender: envelope.sender });
60
146
  this.ballots.set(requestId, senderMap);
61
147
  }
62
148
  approvalCount(requestId) {
@@ -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 TaskRecord {
@@ -37,10 +38,51 @@ export declare class TaskProjection {
37
38
  readonly updates: TaskUpdateRecord[];
38
39
  readonly completions: TaskCompletionRecord[];
39
40
  readonly failures: TaskFailureRecord[];
41
+ /**
42
+ * The session's accepted history, one envelope per unique `message_id`. See
43
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
44
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
45
+ * only as a one-line pointer.
46
+ */
40
47
  readonly transcript: Envelope[];
48
+ /**
49
+ * Cardinality anomalies recorded while replaying this projection's accepted
50
+ * transcript. See `BaseProjection.anomalies` (`src/projections/base.ts`)
51
+ * for the canonical description; duplicated here only as a one-line
52
+ * pointer. No built-in detection populates this for Task mode.
53
+ */
54
+ readonly anomalies: ProjectionAnomaly[];
41
55
  phase: 'Pending' | 'Requested' | 'InProgress' | 'Completed' | 'Failed' | 'Committed';
42
56
  commitment?: Record<string, unknown>;
57
+ /**
58
+ * `message_id`s already applied to this projection. See
59
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
60
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
61
+ * only as a one-line pointer.
62
+ */
63
+ private readonly seenMessageIds;
64
+ /**
65
+ * Apply one envelope to this projection's in-process state.
66
+ *
67
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
68
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
69
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
70
+ * mode are documented once, on `BaseProjection.applyEnvelope`
71
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
72
+ * pointer so six independent copies of the same prose cannot drift.
73
+ *
74
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
75
+ * non-event — not appended to `transcript`, not passed to the `switch`.
76
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
77
+ * citation; duplicated here only as a one-line pointer.
78
+ */
43
79
  applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
80
+ /**
81
+ * True once at least one `ProjectionAnomaly` has been recorded. See
82
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
83
+ * canonical description; duplicated here only as a one-line pointer.
84
+ */
85
+ get hasAnomalies(): boolean;
44
86
  get isCommitted(): boolean;
45
87
  get isPositiveOutcome(): boolean | undefined;
46
88
  getTask(taskId: string): TaskRecord | undefined;
@@ -2,17 +2,64 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TaskProjection = void 0;
4
4
  const constants_1 = require("../constants");
5
+ const logging_1 = require("../logging");
5
6
  class TaskProjection {
6
7
  tasks = new Map();
7
8
  updates = [];
8
9
  completions = [];
9
10
  failures = [];
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. See `BaseProjection.anomalies` (`src/projections/base.ts`)
21
+ * for the canonical description; duplicated here only as a one-line
22
+ * pointer. No built-in detection populates this for Task mode.
23
+ */
24
+ anomalies = [];
11
25
  phase = 'Pending';
12
26
  commitment;
27
+ /**
28
+ * `message_id`s already applied to this projection. See
29
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
30
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
31
+ * only as a one-line pointer.
32
+ */
33
+ seenMessageIds = new Set();
34
+ /**
35
+ * Apply one envelope to this projection's in-process state.
36
+ *
37
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
38
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
39
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
40
+ * mode are documented once, on `BaseProjection.applyEnvelope`
41
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
42
+ * pointer so six independent copies of the same prose cannot drift.
43
+ *
44
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
45
+ * non-event — not appended to `transcript`, not passed to the `switch`.
46
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
47
+ * citation; duplicated here only as a one-line pointer.
48
+ */
13
49
  applyEnvelope(envelope, protoRegistry) {
14
50
  if (envelope.mode !== constants_1.MODE_TASK)
15
51
  return;
52
+ if (envelope.messageId) {
53
+ if (this.seenMessageIds.has(envelope.messageId)) {
54
+ logging_1.logger.debug('projection redelivery ignored', {
55
+ messageId: envelope.messageId,
56
+ mode: envelope.mode,
57
+ messageType: envelope.messageType,
58
+ });
59
+ return;
60
+ }
61
+ this.seenMessageIds.add(envelope.messageId);
62
+ }
16
63
  this.transcript.push(envelope);
17
64
  const payload = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
18
65
  switch (envelope.messageType) {
@@ -87,6 +134,14 @@ class TaskProjection {
87
134
  break;
88
135
  }
89
136
  }
137
+ /**
138
+ * True once at least one `ProjectionAnomaly` has been recorded. See
139
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
140
+ * canonical description; duplicated here only as a one-line pointer.
141
+ */
142
+ get hasAnomalies() {
143
+ return this.anomalies.length > 0;
144
+ }
90
145
  get isCommitted() {
91
146
  return this.commitment !== undefined;
92
147
  }
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.7.1";
7
+ export declare const VERSION = "0.8.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.7.1'; // x-release-please-version
10
+ exports.VERSION = '0.8.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macp-sdk-typescript",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "TypeScript SDK for the MACP Rust runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@grpc/grpc-js": "^1.14.3",
57
57
  "@grpc/proto-loader": "^0.8.0",
58
- "@multiagentcoordinationprotocol/proto": "^0.1.8",
58
+ "@multiagentcoordinationprotocol/proto": "^0.1.9",
59
59
  "protobufjs": "^8.6.5"
60
60
  },
61
61
  "devDependencies": {