macp-sdk-typescript 0.11.0 → 0.12.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +1 -1
  3. package/dist/agent/index.d.ts +1 -1
  4. package/dist/agent/index.js +2 -1
  5. package/dist/agent/participant.d.ts +47 -4
  6. package/dist/agent/participant.js +88 -10
  7. package/dist/agent/strategies.js +23 -7
  8. package/dist/agent/transports.d.ts +19 -1
  9. package/dist/agent/transports.js +108 -66
  10. package/dist/auth.d.ts +15 -4
  11. package/dist/auth.js +23 -7
  12. package/dist/base-session.js +13 -3
  13. package/dist/client.js +58 -15
  14. package/dist/commitment-hash.d.ts +33 -3
  15. package/dist/commitment-hash.js +39 -5
  16. package/dist/decision.d.ts +1 -0
  17. package/dist/decision.js +11 -9
  18. package/dist/envelope.js +9 -1
  19. package/dist/handoff.d.ts +1 -0
  20. package/dist/handoff.js +19 -6
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +18 -7
  23. package/dist/projections/base.d.ts +71 -14
  24. package/dist/projections/base.js +83 -12
  25. package/dist/projections/decision.js +23 -3
  26. package/dist/projections/handoff.js +39 -16
  27. package/dist/projections/proposal.js +16 -2
  28. package/dist/projections/quorum.js +13 -0
  29. package/dist/projections/task.d.ts +27 -4
  30. package/dist/projections/task.js +44 -13
  31. package/dist/proposal.d.ts +1 -0
  32. package/dist/proposal.js +2 -2
  33. package/dist/proto-registry.d.ts +69 -3
  34. package/dist/proto-registry.js +83 -5
  35. package/dist/quorum.d.ts +1 -0
  36. package/dist/quorum.js +4 -4
  37. package/dist/task.d.ts +1 -0
  38. package/dist/task.js +24 -11
  39. package/dist/types.d.ts +14 -7
  40. package/dist/validation.d.ts +14 -0
  41. package/dist/validation.js +39 -5
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +3 -2
@@ -1,13 +1,63 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BaseProjection = void 0;
3
+ exports.BaseProjection = exports.PROJECTION_ANOMALY_FIELD_ORDER = exports.ANOMALY_SETTLED_HANDOFF = exports.ANOMALY_DUPLICATE_TASK_ACCEPT = exports.ANOMALY_DUPLICATE_BALLOT = exports.ANOMALY_DUPLICATE_VOTE = void 0;
4
4
  const logging_1 = require("../logging");
5
+ /**
6
+ * The four `ProjectionAnomalyKind` values as named constants — all four are
7
+ * cross-SDK contract, literally pinned by the spec repo's
8
+ * `schemas/parity/contract.json` (`projection_anomaly.kinds`, `contract_version`
9
+ * 1.2.0 — `ANOMALY_DUPLICATE_TASK_ACCEPT`/`ANOMALY_SETTLED_HANDOFF` landed there
10
+ * under issue #126/#128, after this SDK's PR #134 completed the agreement
11
+ * `macp-sdk-python`'s PR #95 started). `satisfies` links each constant to the
12
+ * union above at compile time, so a drifted value fails `npm run check` before
13
+ * it can ever reach `make verify-parity`.
14
+ */
15
+ exports.ANOMALY_DUPLICATE_VOTE = 'duplicate_vote';
16
+ exports.ANOMALY_DUPLICATE_BALLOT = 'duplicate_ballot';
17
+ exports.ANOMALY_DUPLICATE_TASK_ACCEPT = 'duplicate_task_accept';
18
+ exports.ANOMALY_SETTLED_HANDOFF = 'settled_handoff';
19
+ /**
20
+ * Runtime field order for `ProjectionAnomaly`, with the same frozen-set
21
+ * guarantee as `_ProjectionAnomalyFieldSetIsFrozen` above, expressed as a
22
+ * value rather than only a type. The spec repo's
23
+ * `schemas/parity/contract.json` (`projection_anomaly.fields`) pins this
24
+ * field order as a runtime JSON array, so this SDK needs its own runtime
25
+ * array to assert against it (see `tests/parity/contract.test.ts`).
26
+ *
27
+ * **Public, not hidden — corrected during Phase 2's own verification round.**
28
+ * An earlier revision of this docblock claimed this constant was
29
+ * "deliberately NOT added to the public barrel," on the (wrong) assumption
30
+ * that it would follow `canonicalizeCommitmentPayload`'s precedent in
31
+ * `commitment-hash.ts`. That precedent doesn't transfer: `commitment-hash.ts`
32
+ * is barrel-exported via a *named* line in `src/index.ts`
33
+ * (`export { commitmentHash, isCanonicalCommitmentHash } from
34
+ * './commitment-hash'`), which can omit a name on purpose. This module is
35
+ * barrel-exported via `src/projections.ts`'s `export * from
36
+ * './projections/base'` → `src/index.ts`'s `export * from './projections'`
37
+ * — a wildcard re-export chain has no way to omit one name, so this constant
38
+ * reaches `dist/index.d.ts` and the runtime `require('macp-sdk-typescript')`
39
+ * surface regardless of intent (verified: `node -e "require('./dist/index.js')
40
+ * .PROJECTION_ANOMALY_FIELD_ORDER"` prints the array). This is harmless —
41
+ * a small readonly string tuple is no more a surface-area risk than
42
+ * `STANDARD_MODES` — so the fix is documenting reality, not restructuring
43
+ * the export chain to force exclusion. Phase 3's public-API snapshot
44
+ * correctly captures this symbol as part of today's real surface.
45
+ */
46
+ exports.PROJECTION_ANOMALY_FIELD_ORDER = [
47
+ 'kind',
48
+ 'mode',
49
+ 'messageType',
50
+ 'messageId',
51
+ 'sender',
52
+ 'subjectId',
53
+ 'detail',
54
+ ];
5
55
  /**
6
56
  * Abstract base for in-process mode-state tracking — parity with python-sdk's
7
57
  * `macp_sdk.base_projection.BaseProjection`. Maintains a shared transcript,
8
58
  * phase string, and commitment payload; subclasses override `applyMode`
9
- * to handle mode-specific envelopes. `SessionStart` and `Commitment` are
10
- * handled here so custom modes get the common lifecycle for free.
59
+ * to handle mode-specific envelopes. `Commitment` is handled here so custom
60
+ * modes get the common lifecycle for free.
11
61
  */
12
62
  class BaseProjection {
13
63
  /**
@@ -38,18 +88,27 @@ class BaseProjection {
38
88
  /**
39
89
  * Cardinality anomalies recorded while replaying this projection's
40
90
  * 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.
91
+ * sender, a duplicate `TaskAccept` competing for an already-held assignee
92
+ * slot, or a `HandoffAccept`/`HandoffDecline` for an already-settled
93
+ * handoff). See `ProjectionAnomaly`. Empty unless a subclass calls
94
+ * `recordAnomaly`. **Corrected 2026-09-25** (this note previously said
95
+ * nothing calls `recordAnomaly` because `DecisionProjection`/
96
+ * `QuorumProjection` didn't extend `BaseProjection` — that was true before
97
+ * issue #91's refactor, and is stale now): both `DecisionProjection`
98
+ * (`decision.ts`) and `QuorumProjection` (`quorum.ts`) extend
99
+ * `BaseProjection` today and DO call the inherited `recordAnomaly` when
100
+ * they discard a duplicate vote/ballot — they just pass a literal
101
+ * `'duplicate_vote'`/`'duplicate_ballot'` string for `kind` rather than
102
+ * this file's `ANOMALY_DUPLICATE_VOTE`/`ANOMALY_DUPLICATE_BALLOT`
103
+ * constants (referencing those constants instead is optional cleanup, not
104
+ * required by anything in this repo). This field and `recordAnomaly` are
105
+ * no longer ext-mode-only — any subclass, built-in or ext-mode, uses them.
47
106
  */
48
107
  anomalies = [];
49
108
  /**
50
109
  * 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
110
+ * projection observed and discarded a cardinality anomaly (see `anomalies`
111
+ * above for the full set of kinds). This getter is exactly
53
112
  * `anomalies.length > 0`; it never asserts "this transcript violates the
54
113
  * spec" (see `ProjectionAnomaly`'s docblock, and `applyEnvelope`'s "Input
55
114
  * contract: accepted-only" section above, for why a projection cannot make
@@ -157,7 +216,19 @@ class BaseProjection {
157
216
  this.applyMode(envelope, protoRegistry);
158
217
  }
159
218
  catch (err) {
160
- this.transcript.pop();
219
+ // Identity (`===`), not deep equality: guards against popping an
220
+ // unrelated entry if a subclass's `applyMode` pushed something onto
221
+ // `transcript` itself before throwing (issue #106.4) — two distinct
222
+ // envelopes can be structurally equal, but only the one this call
223
+ // itself pushed should ever be popped here. No current subclass does
224
+ // this (nothing pushes before the fallible decode above), so this
225
+ // guard is latent, but `BaseProjection` is documented subclassable
226
+ // API and the failure mode it prevents is worse than the bug the
227
+ // rollback itself fixes. The dedup-set delete below needs no such
228
+ // guard — `seenIdAdded` already keys it to this envelope's own id —
229
+ // and must still run even when the pop is skipped.
230
+ if (this.transcript.at(-1) === envelope)
231
+ this.transcript.pop();
161
232
  if (seenIdAdded)
162
233
  this.seenMessageIds.delete(envelope.messageId);
163
234
  throw err;
@@ -37,6 +37,23 @@ class DecisionProjection extends base_1.BaseProjection {
37
37
  }
38
38
  case 'Vote': {
39
39
  const record = payload;
40
+ // RFC-MACP-0007 §5 rule 2: "Evaluation, Objection, and Vote MUST
41
+ // reference an existing proposal_id." A conforming runtime never
42
+ // admits a Vote for an unknown proposal_id into accepted history, but
43
+ // per the accepted-only input contract (`BaseProjection.applyEnvelope`)
44
+ // this guards the same class of unfiltered-transcript input that
45
+ // `HandoffAccept`/`HandoffDecline` already guard against (issue #119,
46
+ // mirrors issue #111's task.ts fix). Evaluation/Objection need no
47
+ // matching guard: they only append to audit arrays (see
48
+ // `docs/api/projections.md` "List accumulators and logical
49
+ // duplicates"), so an unknown-proposalId record there fabricates no
50
+ // Map state and moves no phase. Vote is different: without this
51
+ // guard, `this.votes.get(...) ?? new Map()` below would fabricate a
52
+ // new `votes` entry for an unknown proposalId, inflating
53
+ // `voteTotals()`/`majorityWinner()`'s denominators even though no
54
+ // known proposal exists.
55
+ if (!this.proposals.has(record.proposalId))
56
+ break;
40
57
  const bySender = this.votes.get(record.proposalId) ?? new Map();
41
58
  const kept = bySender.get(envelope.sender);
42
59
  if (kept !== undefined) {
@@ -68,9 +85,12 @@ class DecisionProjection extends base_1.BaseProjection {
68
85
  // not a normative MACP term (it appears once, in passing, at
69
86
  // RFC-MACP-0012 `:211`); this guard is justified by session
70
87
  // terminality, not by a phase specification. An anomaly would be
71
- // recorded here too, but `ProjectionAnomalyKind` (`base.ts:8-9`) is
72
- // deliberately frozen pending cross-SDK agreement with
73
- // macp-sdk-python.
88
+ // recorded here too, but unlike five of the six sites that were
89
+ // "frozen pending cross-SDK agreement" (issue #126/#128, now
90
+ // resolved for `duplicate_task_accept`/`settled_handoff` — see
91
+ // `base.ts`), this one is a session-terminality phase-regression
92
+ // guard, not a settlement discard, and remains genuinely undecided
93
+ // per #128's own explicit scoping. No behavior change here.
74
94
  if (this.phase !== 'Committed') {
75
95
  this.phase = 'Voting';
76
96
  }
@@ -27,6 +27,11 @@ class HandoffProjection extends base_1.BaseProjection {
27
27
  case 'HandoffContext': {
28
28
  const record = payload;
29
29
  const handoff = this.handoffs.get(record.handoffId);
30
+ // RFC-MACP-0010 §5 rule 2: HandoffContext MUST reference an existing
31
+ // handoff_id, so the phase transition belongs inside this guard too
32
+ // (issue #119, same shape as issue #111) — a HandoffContext for an
33
+ // unknown handoff_id must not advance `phase` past `'OfferPending'`
34
+ // while a different, real handoff is still pending.
30
35
  if (handoff) {
31
36
  // Per RFC-MACP-0010 §2.1: context after accept is permitted as supplementary docs.
32
37
  // Only update status if not already accepted/declined.
@@ -34,9 +39,9 @@ class HandoffProjection extends base_1.BaseProjection {
34
39
  handoff.status = 'context_sent';
35
40
  }
36
41
  handoff.contextContentType = record.contentType;
42
+ if (this.phase === 'OfferPending')
43
+ this.phase = 'ContextSharing';
37
44
  }
38
- if (this.phase === 'OfferPending')
39
- this.phase = 'ContextSharing';
40
45
  break;
41
46
  }
42
47
  case 'HandoffAccept': {
@@ -47,9 +52,10 @@ class HandoffProjection extends base_1.BaseProjection {
47
52
  // and HandoffDecline MUST reference an existing handoff_id." An
48
53
  // accept for an unknown/never-offered handoff_id is invalid input
49
54
  // (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
50
- // fabricate a handoff record. An anomaly would be recorded here,
51
- // but `ProjectionAnomalyKind` (`base.ts:8-9`) is deliberately
52
- // frozen pending cross-SDK agreement with macp-sdk-python.
55
+ // fabricate a handoff record. Confirmed (issue #126/#128): no
56
+ // anomaly is recorded for this case — an unknown `handoff_id` is
57
+ // not caller misuse (a projection that joined mid-session may never
58
+ // have observed the original offer).
53
59
  break;
54
60
  }
55
61
  // RFC-MACP-0010 §5 rule 4 (`:68`): "Once an offer has been
@@ -58,10 +64,7 @@ class HandoffProjection extends base_1.BaseProjection {
58
64
  // direction too: a `handoff_id` transitions
59
65
  // offered -> accepted | declined exactly once. Only settle if this
60
66
  // handoff hasn't already resolved — the same shape as the
61
- // HandoffContext guard just above. An anomaly would be recorded when
62
- // this guard discards a competing accept, but `ProjectionAnomalyKind`
63
- // (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
64
- // with macp-sdk-python.
67
+ // HandoffContext guard just above.
65
68
  if (handoff.status === 'offered' || handoff.status === 'context_sent') {
66
69
  handoff.status = 'accepted';
67
70
  handoff.acceptedBy = record.acceptedBy;
@@ -71,6 +74,17 @@ class HandoffProjection extends base_1.BaseProjection {
71
74
  handoff.implicit = record.implicit ?? false;
72
75
  this.phase = 'Accepted';
73
76
  }
77
+ else {
78
+ this.recordAnomaly({
79
+ kind: 'settled_handoff',
80
+ mode: envelope.mode,
81
+ messageType: envelope.messageType,
82
+ messageId: envelope.messageId,
83
+ sender: envelope.sender,
84
+ subjectId: record.handoffId,
85
+ detail: `sender ${envelope.sender} attempted HandoffAccept for ${record.handoffId}, but it already settled as '${handoff.status}'`,
86
+ });
87
+ }
74
88
  break;
75
89
  }
76
90
  case 'HandoffDecline': {
@@ -81,22 +95,31 @@ class HandoffProjection extends base_1.BaseProjection {
81
95
  // and HandoffDecline MUST reference an existing handoff_id." A
82
96
  // decline for an unknown/never-offered handoff_id is invalid input
83
97
  // (e.g. an unfiltered transcript) and MUST NOT mutate `phase` or
84
- // fabricate a handoff record. An anomaly would be recorded here,
85
- // but `ProjectionAnomalyKind` (`base.ts:8-9`) is deliberately
86
- // frozen pending cross-SDK agreement with macp-sdk-python.
98
+ // fabricate a handoff record. Confirmed (issue #126/#128): no
99
+ // anomaly is recorded for this case — an unknown `handoff_id` is
100
+ // not caller misuse (a projection that joined mid-session may never
101
+ // have observed the original offer).
87
102
  break;
88
103
  }
89
104
  // RFC-MACP-0010 §5 rule 4 (`:68`) + §5.1(4) (`:113-116`): a
90
105
  // `handoff_id` settles once. A decline after the handoff already
91
- // settled (accepted or declined) is invalid and ignored. An anomaly
92
- // would be recorded here too, but `ProjectionAnomalyKind`
93
- // (`base.ts:8-9`) is deliberately frozen pending cross-SDK agreement
94
- // with macp-sdk-python.
106
+ // settled (accepted or declined) is invalid and ignored.
95
107
  if (handoff.status === 'offered' || handoff.status === 'context_sent') {
96
108
  handoff.status = 'declined';
97
109
  handoff.declinedBy = record.declinedBy;
98
110
  this.phase = 'Declined';
99
111
  }
112
+ else {
113
+ this.recordAnomaly({
114
+ kind: 'settled_handoff',
115
+ mode: envelope.mode,
116
+ messageType: envelope.messageType,
117
+ messageId: envelope.messageId,
118
+ sender: envelope.sender,
119
+ subjectId: record.handoffId,
120
+ detail: `sender ${envelope.sender} attempted HandoffDecline for ${record.handoffId}, but it already settled as '${handoff.status}'`,
121
+ });
122
+ }
100
123
  break;
101
124
  }
102
125
  default:
@@ -69,11 +69,25 @@ class ProposalProjection extends base_1.BaseProjection {
69
69
  reason: record.reason,
70
70
  sender: envelope.sender,
71
71
  });
72
+ // RFC-MACP-0008 §5 rule 3 requires `Reject` to reference an existing
73
+ // proposal_id, so `this.phase` must only move to the terminal state
74
+ // when a proposal record for it actually exists here (issue #119,
75
+ // same shape as issue #111's TaskComplete/TaskFail fix) — otherwise
76
+ // an unfiltered transcript could flip `phase` to `'TerminalRejected'`
77
+ // (a TERMINAL_PHASES member, see `src/agent/participant.ts`) on a
78
+ // never-seen proposalId, ending a live Participant's run() loop for a
79
+ // session that never actually terminated. `this.rejections.push(...)`
80
+ // above stays unconditional — it's an append-only audit log (see
81
+ // `docs/api/projections.md` "List accumulators and logical
82
+ // duplicates"), so `isTerminallyRejected`/`hasTerminalRejection` can
83
+ // still report `true` for an unknown id even though `phase` does not
84
+ // move; that split is intentional, not a residual bug.
72
85
  if (terminal) {
73
86
  const proposal = this.proposals.get(record.proposalId);
74
- if (proposal)
87
+ if (proposal) {
75
88
  proposal.status = 'rejected';
76
- this.phase = 'TerminalRejected';
89
+ this.phase = 'TerminalRejected';
90
+ }
77
91
  }
78
92
  break;
79
93
  }
@@ -38,6 +38,19 @@ class QuorumProjection extends base_1.BaseProjection {
38
38
  }
39
39
  }
40
40
  setBallot(envelope, requestId, vote, reason) {
41
+ // RFC-MACP-0011 §5 rule 3 (`rfcs/RFC-MACP-0011-quorum-mode.md:69`): a ballot
42
+ // MUST reference the Session's accepted request_id. `applyEnvelope`'s input
43
+ // contract is accepted-only (see accepted-only-contract.test.ts), not a
44
+ // guarantee every field inside an accepted envelope is itself valid, so an
45
+ // unfiltered/multi-session transcript could still carry a ballot for a
46
+ // request_id this projection never saw an ApprovalRequest for. Without this
47
+ // guard that would fabricate a `ballots` Map entry: votedSenders() and
48
+ // approvalCount()/rejectionCount()/abstentionCount() would report on a
49
+ // request that doesn't exist, and a second such ballot from the same sender
50
+ // would fire a spurious duplicate_ballot anomaly against it (issue #121,
51
+ // same bug shape as issue #119's proposal.ts/handoff.ts/decision.ts sites).
52
+ if (!this.requests.has(requestId))
53
+ return;
41
54
  const senderMap = this.ballots.get(requestId) ?? new Map();
42
55
  const kept = senderMap.get(envelope.sender);
43
56
  if (kept !== undefined) {
@@ -19,13 +19,21 @@ export interface TaskUpdateRecord {
19
19
  message?: string;
20
20
  sender: string;
21
21
  }
22
- export interface TaskCompletionRecord {
22
+ export interface TaskCompleteRecord {
23
23
  taskId: string;
24
24
  assignee: string;
25
25
  summary?: string;
26
26
  sender: string;
27
27
  }
28
- export interface TaskFailureRecord {
28
+ /**
29
+ * @deprecated Use {@link TaskCompleteRecord}. Renamed for cross-SDK naming
30
+ * parity (multiagentcoordinationprotocol#135, issue #138) — the record now
31
+ * mirrors its triggering `TaskComplete` message type the way its sibling
32
+ * {@link TaskUpdateRecord} already mirrors `TaskUpdate`, and matches
33
+ * `macp-sdk-python`'s `TaskCompleteRecord`. Scheduled for removal in 0.13.0.
34
+ */
35
+ export type TaskCompletionRecord = TaskCompleteRecord;
36
+ export interface TaskFailRecord {
29
37
  taskId: string;
30
38
  assignee: string;
31
39
  errorCode?: string;
@@ -33,12 +41,20 @@ export interface TaskFailureRecord {
33
41
  retryable: boolean;
34
42
  sender: string;
35
43
  }
44
+ /**
45
+ * @deprecated Use {@link TaskFailRecord}. Renamed for cross-SDK naming
46
+ * parity (multiagentcoordinationprotocol#135, issue #138) — the record now
47
+ * mirrors its triggering `TaskFail` message type the way its sibling
48
+ * {@link TaskUpdateRecord} already mirrors `TaskUpdate`, and matches
49
+ * `macp-sdk-python`'s `TaskFailRecord`. Scheduled for removal in 0.13.0.
50
+ */
51
+ export type TaskFailureRecord = TaskFailRecord;
36
52
  export declare class TaskProjection extends BaseProjection {
37
53
  protected readonly mode = "macp.mode.task.v1";
38
54
  readonly tasks: Map<string, TaskRecord>;
39
55
  readonly updates: TaskUpdateRecord[];
40
- readonly completions: TaskCompletionRecord[];
41
- readonly failures: TaskFailureRecord[];
56
+ readonly completions: TaskCompleteRecord[];
57
+ readonly failures: TaskFailRecord[];
42
58
  phase: 'Pending' | 'Requested' | 'InProgress' | 'Completed' | 'Failed' | 'Committed';
43
59
  /**
44
60
  * The **session-level** active assignment: who currently holds the session's
@@ -63,6 +79,13 @@ export declare class TaskProjection extends BaseProjection {
63
79
  /** Handle a Task-mode envelope. See `BaseProjection.applyEnvelope` for the accepted-only input contract and redelivery/rollback semantics shared by all built-in modes. */
64
80
  protected applyMode(envelope: Envelope, protoRegistry: ProtoRegistry): void;
65
81
  getTask(taskId: string): TaskRecord | undefined;
82
+ isCompleted(taskId: string): boolean;
83
+ /**
84
+ * @deprecated Use {@link isCompleted}. Renamed for cross-SDK naming parity
85
+ * (multiagentcoordinationprotocol#135, issue #138) — every other `is*`
86
+ * predicate in this SDK is past-participle, and `macp-sdk-python` has
87
+ * always spelled this `is_completed`. Scheduled for removal in 0.13.0.
88
+ */
66
89
  isComplete(taskId: string): boolean;
67
90
  isFailed(taskId: string): boolean;
68
91
  isRetryable(taskId: string): boolean;
@@ -74,16 +74,37 @@ class TaskProjection extends base_1.BaseProjection {
74
74
  // below, which frees the slot — see its comment for why that needs no
75
75
  // policy input.
76
76
  //
77
- // An anomaly would be recorded when this guard discards a second
78
- // `TaskAccept`, but `ProjectionAnomalyKind` (`base.ts:8-9`) is
79
- // deliberately frozen pending cross-SDK agreement with
80
- // macp-sdk-python.
81
- if (task && this.activeAssignment === undefined) {
82
- task.assignee = record.assignee;
83
- task.status = 'accepted';
84
- this.activeAssignment = { sender: envelope.sender, taskId: record.taskId };
85
- this.phase = 'InProgress';
77
+ // A discarded second `TaskAccept` records a `duplicate_task_accept`
78
+ // anomaly (issue #126/#128) only when this projection actually has a
79
+ // `TaskRequest` on file for the losing `taskId` (`task` is defined) —
80
+ // an accept for a `taskId` this projection never saw a `TaskRequest`
81
+ // for stays a silent no-op below, since a projection that joined
82
+ // mid-session may never have observed the original request, and that
83
+ // is not caller misuse. Note this guard's `activeAssignment` slot is
84
+ // session-scoped (see the comment above), so the `taskId` that loses
85
+ // here need not be the same one the slot is already held for — it is
86
+ // simply a second, separately-requested task competing for the one
87
+ // slot the session allows.
88
+ if (task) {
89
+ if (this.activeAssignment === undefined) {
90
+ task.assignee = record.assignee;
91
+ task.status = 'accepted';
92
+ this.activeAssignment = { sender: envelope.sender, taskId: record.taskId };
93
+ this.phase = 'InProgress';
94
+ }
95
+ else {
96
+ this.recordAnomaly({
97
+ kind: 'duplicate_task_accept',
98
+ mode: envelope.mode,
99
+ messageType: envelope.messageType,
100
+ messageId: envelope.messageId,
101
+ sender: envelope.sender,
102
+ subjectId: record.taskId,
103
+ detail: `sender ${envelope.sender} attempted TaskAccept for ${record.taskId}, but ${this.activeAssignment.sender} already holds the session's one assignee slot with task ${this.activeAssignment.taskId}`,
104
+ });
105
+ }
86
106
  }
107
+ // unknown taskId (task undefined): stays a silent no-op.
87
108
  break;
88
109
  }
89
110
  case 'TaskReject': {
@@ -137,17 +158,18 @@ class TaskProjection extends base_1.BaseProjection {
137
158
  if (task) {
138
159
  task.status = 'completed';
139
160
  task.progress = 1;
161
+ this.phase = 'Completed';
140
162
  }
141
- this.phase = 'Completed';
142
163
  break;
143
164
  }
144
165
  case 'TaskFail': {
145
166
  const record = payload;
146
167
  this.failures.push({ ...record, retryable: record.retryable ?? false, sender: envelope.sender });
147
168
  const task = this.tasks.get(record.taskId);
148
- if (task)
169
+ if (task) {
149
170
  task.status = 'failed';
150
- this.phase = 'Failed';
171
+ this.phase = 'Failed';
172
+ }
151
173
  break;
152
174
  }
153
175
  default:
@@ -157,9 +179,18 @@ class TaskProjection extends base_1.BaseProjection {
157
179
  getTask(taskId) {
158
180
  return this.tasks.get(taskId);
159
181
  }
160
- isComplete(taskId) {
182
+ isCompleted(taskId) {
161
183
  return this.tasks.get(taskId)?.status === 'completed';
162
184
  }
185
+ /**
186
+ * @deprecated Use {@link isCompleted}. Renamed for cross-SDK naming parity
187
+ * (multiagentcoordinationprotocol#135, issue #138) — every other `is*`
188
+ * predicate in this SDK is past-participle, and `macp-sdk-python` has
189
+ * always spelled this `is_completed`. Scheduled for removal in 0.13.0.
190
+ */
191
+ isComplete(taskId) {
192
+ return this.isCompleted(taskId);
193
+ }
163
194
  isFailed(taskId) {
164
195
  return this.tasks.get(taskId)?.status === 'failed';
165
196
  }
@@ -32,6 +32,7 @@ export declare class ProposalSession {
32
32
  }[];
33
33
  maxSuspendMs?: number;
34
34
  sender?: string;
35
+ auth?: AuthConfig;
35
36
  }): Promise<Ack>;
36
37
  propose(input: ProposalModeProposalPayload & {
37
38
  sender?: string;
package/dist/proposal.js CHANGED
@@ -60,10 +60,10 @@ class ProposalSession {
60
60
  mode: constants_1.MODE_PROPOSAL,
61
61
  messageType: 'SessionStart',
62
62
  sessionId: this.sessionId,
63
- sender: this.senderFor(input.sender),
63
+ sender: this.senderFor(input.sender, input.auth),
64
64
  payload: this.client.protoRegistry.encodeKnownPayload(constants_1.MODE_PROPOSAL, 'SessionStart', (0, envelope_1.toProtoPayload)(payload)),
65
65
  });
66
- return this.sendAndTrack(envelope, this.auth);
66
+ return this.sendAndTrack(envelope, input.auth ?? this.auth);
67
67
  }
68
68
  async propose(input) {
69
69
  (0, validation_1.validateRequiredField)('proposalId', input.proposalId);
@@ -11,7 +11,13 @@ export declare class ProtoRegistry {
11
11
  * Decode an `ext.multi_round.v1` `Contribute` payload, accepting both wire
12
12
  * formats: legacy JSON (`{"value":"..."}`, still replayed verbatim from
13
13
  * pre-proto histories) and canonical protobuf (`ContributePayload`). Both
14
- * normalize to `{ value: string }`.
14
+ * normalize to `{ value }`, but only the protobuf path's `value` is always
15
+ * a `string` — the legacy-JSON path passes `parsed.value` through exactly
16
+ * as `JSON.parse` produced it (issue #124: no `String(...)` coercion, no
17
+ * `?? ''` fallback for an absent key), so `value` may be `undefined`,
18
+ * `null`, a number, a boolean, or an object/array for malformed-but-valid
19
+ * legacy JSON. This method's own contract is observational, not a gate —
20
+ * see the `contribute_acceptance` note below.
15
21
  *
16
22
  * Parse-then-fallback (issue #93), not a first-byte shortcut: JSON is always
17
23
  * attempted first, and only a `JSON.parse` failure falls through to
@@ -19,8 +25,27 @@ export declare class ProtoRegistry {
19
25
  * not generalize — e.g. legacy JSON with leading whitespace has neither byte
20
26
  * first, and used to reach `decodeMessage` and throw. Parity with
21
27
  * `macp-sdk-python`'s `_decode_json_first_then_proto`, which has always
22
- * worked this way. Safe because a canonical proto payload's bytes are
23
- * essentially never also syntactically valid JSON.
28
+ * worked this way.
29
+ *
30
+ * **A successful `JSON.parse` is not proof the bytes are legacy JSON**
31
+ * (issue #104 — this method's prior "essentially never also syntactically
32
+ * valid JSON" claim was false, not merely imprecise): the canonical proto
33
+ * tag byte for field 1 (`0x0A`) is itself insignificant JSON whitespace, so
34
+ * for specific value byte-lengths the length varint — or, when the varint
35
+ * is itself whitespace, the value's own leading byte — becomes the first
36
+ * significant character `JSON.parse` sees, and a genuine `ContributePayload`
37
+ * silently misreads as a JSON number/string/object. Verified directly
38
+ * (encode-then-decode sweep, value lengths 1-127, seven value shapes): every
39
+ * one of lengths 9, 10, 13, 32, 34, 45, 48, 49-57, and 123 corrupted before
40
+ * `isCanonicalProto` below, most to total silent data loss
41
+ * (`String(undefined ?? '') === ''`), zero corrupt after.
42
+ *
43
+ * `isCanonicalProto` resolves the ambiguity by tie-break, not by refusing
44
+ * to try JSON first: bytes that parse as JSON *and* are the exact canonical
45
+ * proto encoding are treated as proto. This has a narrow, priced cost on
46
+ * the reverse direction (legacy JSON misread as proto) that it introduces
47
+ * rather than merely fails to close — see `isCanonicalProto`'s own
48
+ * docblock.
24
49
  *
25
50
  * An empty payload falls through the same path: `JSON.parse('')` throws, so
26
51
  * it reaches `decodeMessage` on zero bytes, which yields `{}` (proto3
@@ -31,6 +56,47 @@ export declare class ProtoRegistry {
31
56
  * currently `macp-runtime`-only), not something this method decides.
32
57
  */
33
58
  private decodeMultiRoundContribute;
59
+ /**
60
+ * True iff `payload` is the exact canonical protobuf encoding of
61
+ * `typeName` — used to break the JSON/proto ambiguity in
62
+ * `decodeMultiRoundContribute` (issue #104): bytes that both parse as JSON
63
+ * *and* round-trip byte-identically through the proto message are treated
64
+ * as proto, not JSON.
65
+ *
66
+ * `macp-sdk-python`'s equivalent (`_is_canonical_proto`) needs an explicit
67
+ * `DiscardUnknownFields()` step before comparing, because Python's protobuf
68
+ * runtime preserves unknown fields verbatim through
69
+ * `ParseFromString`/`SerializeToString` by default — without it, a plain
70
+ * round-trip check misclassified several whitespace-prefixed legacy-JSON
71
+ * payloads as canonical proto. **`protobufjs` needs no equivalent step**:
72
+ * verified directly (a synthetic payload with a declared field-1 string
73
+ * plus an undeclared field 2) that `Type.decode()` silently drops fields
74
+ * not in the schema rather than preserving them, so `Type.encode()` can
75
+ * never reproduce bytes it never captured — a byte-exact round-trip here
76
+ * is already proof the payload is *exactly* the single-field encoding, no
77
+ * unknown fields possible. Confirmed this closes the same
78
+ * whitespace-prefixed false-positive class `DiscardUnknownFields` closes
79
+ * in Python (tab/space-prefixed legacy JSON at several lengths).
80
+ *
81
+ * One narrow, symmetric residual survives regardless — genuinely
82
+ * irreducible, not a gap this check merely fails to close: a payload whose
83
+ * first byte is a literal `0x0A` (`\n`) and whose remainder forms a
84
+ * complete, well-formed proto field-1 string with nothing left over has no
85
+ * unknown field for either implementation to expose a mismatch on, because
86
+ * it *is*, byte for byte, both a legal JSON reading and the canonical
87
+ * proto encoding of some (possibly nonsensical) string. Verified instances
88
+ * (this SDK's own `JSON.stringify`, which — unlike Python's `json.dumps`
89
+ * — omits the space after `:`, shifting the colliding lengths by one byte
90
+ * from `macp-sdk-python`'s pinned cases): `'\n' + JSON.stringify({value:
91
+ * 'z'.repeat(112)})`, and with one extra whitespace byte inserted after the
92
+ * `\n`, `'\r'` at value length 1 and `' '` at value length 20. A real
93
+ * legacy-JSON sender would need to deliberately prefix with a literal
94
+ * newline byte for this to matter — this SDK's own JSON producers never
95
+ * emit one — so the trade (fixing the ordinary, naturally-reachable
96
+ * forward-direction corruption above at the cost of this adversarial,
97
+ * unreachable-by-any-known-encoder reverse case) is the right one.
98
+ */
99
+ private isCanonicalProto;
34
100
  private lookupType;
35
101
  private tryDecodeUtf8;
36
102
  }