macp-sdk-typescript 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/README.md +1 -1
- package/dist/agent/transports.d.ts +30 -5
- package/dist/agent/transports.js +103 -13
- package/dist/agent/types.d.ts +12 -0
- package/dist/client.d.ts +7 -3
- package/dist/client.js +7 -3
- package/dist/projections/base.d.ts +149 -0
- package/dist/projections/base.js +126 -0
- package/dist/projections/decision.d.ts +43 -0
- package/dist/projections/decision.js +92 -1
- package/dist/projections/handoff.d.ts +42 -0
- package/dist/projections/handoff.js +95 -4
- package/dist/projections/proposal.d.ts +72 -0
- package/dist/projections/proposal.js +97 -4
- package/dist/projections/quorum.d.ts +43 -0
- package/dist/projections/quorum.js +91 -5
- package/dist/projections/task.d.ts +42 -0
- package/dist/projections/task.js +84 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/projections/base.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|