macp-sdk-typescript 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 +88,44 @@ 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
- this.phase = 'Voting';
112
+ // RFC-MACP-0001 §7.2 (`:218`): RESOLVED is terminal and sessions
113
+ // MUST transition monotonically w.r.t. termination — never back to
114
+ // OPEN/SUSPENDED. §7.3 (`:240`, restated `:249`) says a conforming
115
+ // runtime rejects any session-scoped message once the session is
116
+ // non-OPEN, so a `Vote` cannot legally follow a `Commitment` in
117
+ // accepted history. If one reaches here anyway (the caller violated
118
+ // the accepted-only contract — see `applyEnvelope`'s docblock), do
119
+ // not regress `phase` out of `'Committed'`. Note "phase" itself is
120
+ // not a normative MACP term (it appears once, in passing, at
121
+ // RFC-MACP-0012 `:211`); this guard is justified by session
122
+ // terminality, not by a phase specification. An anomaly would be
123
+ // recorded here too, but `ProjectionAnomalyKind` (`base.ts:8-9`) is
124
+ // deliberately frozen pending cross-SDK agreement with
125
+ // macp-sdk-python.
126
+ if (this.phase !== 'Committed') {
127
+ this.phase = 'Voting';
128
+ }
46
129
  break;
47
130
  }
48
131
  case 'Commitment': {
@@ -54,6 +137,14 @@ class DecisionProjection {
54
137
  break;
55
138
  }
56
139
  }
140
+ /**
141
+ * True once at least one `ProjectionAnomaly` has been recorded. See
142
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
143
+ * canonical description; duplicated here only as a one-line pointer.
144
+ */
145
+ get hasAnomalies() {
146
+ return this.anomalies.length > 0;
147
+ }
57
148
  get isCommitted() {
58
149
  return this.commitment !== undefined;
59
150
  }
@@ -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) {
@@ -44,25 +91,61 @@ class HandoffProjection {
44
91
  case 'HandoffAccept': {
45
92
  const record = payload;
46
93
  const handoff = this.handoffs.get(record.handoffId);
47
- if (handoff) {
94
+ if (!handoff) {
95
+ // RFC-MACP-0010 §5 rule 2 (`:65`): "HandoffContext, HandoffAccept,
96
+ // and HandoffDecline MUST reference an existing handoff_id." An
97
+ // accept for an unknown/never-offered handoff_id is invalid input
98
+ // (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
99
+ // fabricate a handoff record. An anomaly would be recorded here,
100
+ // but `ProjectionAnomalyKind` (`base.ts:8-9`) is deliberately
101
+ // frozen pending cross-SDK agreement with macp-sdk-python.
102
+ break;
103
+ }
104
+ // RFC-MACP-0010 §5 rule 4 (`:68`): "Once an offer has been
105
+ // accepted, no competing accept for that same `handoff_id` is
106
+ // valid." §5.1(4) (`:113-116`) settles the decline-after-accept
107
+ // direction too: a `handoff_id` transitions
108
+ // offered -> accepted | declined exactly once. Only settle if this
109
+ // handoff hasn't already resolved — the same shape as the
110
+ // HandoffContext guard just above. An anomaly would be recorded when
111
+ // this guard discards a competing accept, but `ProjectionAnomalyKind`
112
+ // (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
113
+ // with macp-sdk-python.
114
+ if (handoff.status === 'offered' || handoff.status === 'context_sent') {
48
115
  handoff.status = 'accepted';
49
116
  handoff.acceptedBy = record.acceptedBy;
50
117
  // proto3 bool defaults are materialized to `false` on decode
51
118
  // (proto-registry), so this is always a real boolean once proto 0.1.6
52
119
  // is loaded — `true` marks a runtime synthetic implicit accept.
53
120
  handoff.implicit = record.implicit ?? false;
121
+ this.phase = 'Accepted';
54
122
  }
55
- this.phase = 'Accepted';
56
123
  break;
57
124
  }
58
125
  case 'HandoffDecline': {
59
126
  const record = payload;
60
127
  const handoff = this.handoffs.get(record.handoffId);
61
- if (handoff) {
128
+ if (!handoff) {
129
+ // RFC-MACP-0010 §5 rule 2 (`:65`): "HandoffContext, HandoffAccept,
130
+ // and HandoffDecline MUST reference an existing handoff_id." A
131
+ // decline for an unknown/never-offered handoff_id is invalid input
132
+ // (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
133
+ // fabricate a handoff record. An anomaly would be recorded here,
134
+ // but `ProjectionAnomalyKind` (`base.ts:8-9`) is deliberately
135
+ // frozen pending cross-SDK agreement with macp-sdk-python.
136
+ break;
137
+ }
138
+ // RFC-MACP-0010 §5 rule 4 (`:68`) + §5.1(4) (`:113-116`): a
139
+ // `handoff_id` settles once. A decline after the handoff already
140
+ // settled (accepted or declined) is invalid and ignored. An anomaly
141
+ // would be recorded here too, but `ProjectionAnomalyKind`
142
+ // (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
143
+ // with macp-sdk-python.
144
+ if (handoff.status === 'offered' || handoff.status === 'context_sent') {
62
145
  handoff.status = 'declined';
63
146
  handoff.declinedBy = record.declinedBy;
147
+ this.phase = 'Declined';
64
148
  }
65
- this.phase = 'Declined';
66
149
  break;
67
150
  }
68
151
  case 'Commitment': {
@@ -74,6 +157,14 @@ class HandoffProjection {
74
157
  break;
75
158
  }
76
159
  }
160
+ /**
161
+ * True once at least one `ProjectionAnomaly` has been recorded. See
162
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
163
+ * canonical description; duplicated here only as a one-line pointer.
164
+ */
165
+ get hasAnomalies() {
166
+ return this.anomalies.length > 0;
167
+ }
77
168
  get isCommitted() {
78
169
  return this.commitment !== undefined;
79
170
  }
@@ -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,17 +25,88 @@ export declare class ProposalProjection {
24
25
  readonly proposals: Map<string, ProposalRecord>;
25
26
  readonly accepts: ProposalAcceptRecord[];
26
27
  readonly rejections: ProposalRejectRecord[];
28
+ /**
29
+ * Each sender's current (unsuperseded) `Accept`, keyed by sender.
30
+ * RFC-MACP-0008 §5 rule 5 (`:70`): "A participant MAY change its
31
+ * acceptance target by sending a later `Accept` for a different live
32
+ * proposal. The latest accepted `Accept` from a participant supersedes
33
+ * earlier accepts from the same participant." `accepts` above retains the
34
+ * full append-only history (including superseded accepts) for audit
35
+ * purposes; `isAccepted` and `acceptedProposal` read from this map instead
36
+ * so they reflect the live acceptance set — required for determinism by
37
+ * §7 (`:89`): "Given the same accepted history and the same version-bound
38
+ * rules, implementations MUST derive the same live proposal set, the same
39
+ * acceptance set, and the same commitment eligibility."
40
+ */
41
+ private readonly latestAcceptBySender;
42
+ /**
43
+ * The session's accepted history, one envelope per unique `message_id`. See
44
+ * `BaseProjection.transcript` (`src/projections/base.ts`) for the full
45
+ * redelivery-idempotence contract (RFC-MACP-0006 §3.2); duplicated here
46
+ * only as a one-line pointer.
47
+ */
27
48
  readonly transcript: Envelope[];
49
+ /**
50
+ * Cardinality anomalies recorded while replaying this projection's accepted
51
+ * transcript. See `BaseProjection.anomalies` (`src/projections/base.ts`)
52
+ * for the canonical description; duplicated here only as a one-line
53
+ * pointer. No built-in detection populates this for Proposal mode.
54
+ */
55
+ readonly anomalies: ProjectionAnomaly[];
28
56
  phase: 'Negotiating' | 'TerminalRejected' | 'Committed';
29
57
  commitment?: Record<string, unknown>;
58
+ /**
59
+ * `message_id`s already applied to this projection. See
60
+ * `BaseProjection.seenMessageIds` (`src/projections/base.ts`) for the full
61
+ * redelivery-idempotence rationale (RFC-MACP-0006 §3.2); duplicated here
62
+ * only as a one-line pointer.
63
+ */
64
+ private readonly seenMessageIds;
65
+ /**
66
+ * Apply one envelope to this projection's in-process state.
67
+ *
68
+ * Input contract: **accepted-only**, caller-maintained (`Envelope` carries
69
+ * no acceptance marker). Canonical source: `schemas/conformance/README.md`
70
+ * "Notes:", RFC-MACP-0007 §5.3, RFC-MACP-0011 §5. Full rationale and failure
71
+ * mode are documented once, on `BaseProjection.applyEnvelope`
72
+ * (`src/projections/base.ts`), and duplicated here only as a one-line
73
+ * pointer so six independent copies of the same prose cannot drift.
74
+ *
75
+ * Redelivery idempotence: a redelivered envelope (same `message_id`) is a
76
+ * non-event — not appended to `transcript`, not passed to the `switch`.
77
+ * See `BaseProjection.applyEnvelope` for the full RFC-MACP-0006 §3.2
78
+ * citation; duplicated here only as a one-line pointer.
79
+ */
30
80
  applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void;
81
+ /**
82
+ * True once at least one `ProjectionAnomaly` has been recorded. See
83
+ * `BaseProjection.hasAnomalies` (`src/projections/base.ts`) for the
84
+ * canonical description; duplicated here only as a one-line pointer.
85
+ */
86
+ get hasAnomalies(): boolean;
31
87
  get isCommitted(): boolean;
32
88
  get isPositiveOutcome(): boolean | undefined;
33
89
  activeProposals(): ProposalRecord[];
34
90
  latestProposal(): ProposalRecord | undefined;
91
+ /**
92
+ * True if some participant's *current* (unsuperseded) `Accept` targets
93
+ * `proposalId` — RFC-MACP-0008 §5 rule 5 (`:70`). Note this reads from the
94
+ * live acceptance set, not from `accepts`' full history: a participant
95
+ * whose only accept for `proposalId` was later superseded by a re-accept
96
+ * of a different proposal is not counted.
97
+ */
35
98
  isAccepted(proposalId: string): boolean;
36
99
  isTerminallyRejected(proposalId: string): boolean;
37
100
  liveProposals(): Map<string, ProposalRecord>;
101
+ /**
102
+ * The single proposal every participant's *current* accept targets, or
103
+ * `undefined` if no one has an outstanding accept or outstanding accepts
104
+ * are split across more than one proposal. Computed from the live
105
+ * acceptance set (see `isAccepted`), not from `accepts`' full history — a
106
+ * participant who accepts `p1` and later re-accepts `p2` (RFC-MACP-0008 §5
107
+ * rule 5, `:70`) contributes only `p2`, so this correctly returns `p2`
108
+ * rather than `undefined`.
109
+ */
38
110
  acceptedProposal(): string | undefined;
39
111
  hasTerminalRejection(): boolean;
40
112
  }