macp-sdk-typescript 0.10.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 (50) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/README.md +2 -2
  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/policy.d.ts +54 -7
  24. package/dist/policy.js +143 -19
  25. package/dist/projections/base.d.ts +71 -14
  26. package/dist/projections/base.js +106 -16
  27. package/dist/projections/decision.d.ts +5 -49
  28. package/dist/projections/decision.js +30 -85
  29. package/dist/projections/handoff.d.ts +5 -48
  30. package/dist/projections/handoff.js +44 -93
  31. package/dist/projections/proposal.d.ts +5 -48
  32. package/dist/projections/proposal.js +21 -79
  33. package/dist/projections/quorum.d.ts +5 -49
  34. package/dist/projections/quorum.js +20 -82
  35. package/dist/projections/task.d.ts +31 -51
  36. package/dist/projections/task.js +49 -90
  37. package/dist/proposal.d.ts +1 -0
  38. package/dist/proposal.js +2 -2
  39. package/dist/proto-registry.d.ts +85 -5
  40. package/dist/proto-registry.js +101 -15
  41. package/dist/quorum.d.ts +1 -0
  42. package/dist/quorum.js +4 -4
  43. package/dist/task.d.ts +1 -0
  44. package/dist/task.js +24 -11
  45. package/dist/types.d.ts +14 -7
  46. package/dist/validation.d.ts +14 -0
  47. package/dist/validation.js +39 -5
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +6 -5
package/dist/policy.js CHANGED
@@ -8,27 +8,126 @@ exports.buildHandoffPolicy = buildHandoffPolicy;
8
8
  const errors_1 = require("./errors");
9
9
  // ── Builder helpers ─────────────────────────────────────────────────
10
10
  function serializeCommitment(commitment) {
11
+ // `authority: 'designated_role'` with an empty (or unset) `designatedRoles`
12
+ // names no one, so no sender could ever satisfy it. All five rule schemas
13
+ // reject this combination at admission (spec PR #121) via a root-level
14
+ // `allOf` conditional arm, not an inline constraint on the property, so it
15
+ // is easy to miss. Validated once, here, so every mode builder gets it
16
+ // uniformly -- mirrors python-sdk `_commitment_dict`, policy.py:49-60.
17
+ //
18
+ // Explicit length check, not a truthiness test (Python's `not
19
+ // c.designated_roles`): the omitted-field and supplied-`[]` cases both
20
+ // collapse to length 0 here, and both must throw -- unlike Phase 3's
21
+ // weights rule, there is no supplied-vs-absent distinction to preserve.
22
+ if (commitment?.authority === 'designated_role' && (commitment.designatedRoles?.length ?? 0) === 0) {
23
+ throw new errors_1.MacpSessionError("commitment.authority is 'designated_role' but designatedRoles is empty -- this names no one, " +
24
+ 'so no sender could ever satisfy it. Pass at least one role/participant id in designatedRoles.');
25
+ }
11
26
  return {
12
27
  authority: commitment?.authority ?? 'initiator_only',
13
28
  designated_roles: commitment?.designatedRoles ?? [],
14
- // Parity with python-sdk `_commitment_dict`: emitted for every mode, not
15
- // just decision, so policy JSON is byte-identical across SDKs.
16
- require_vote_quorum: commitment?.requireVoteQuorum ?? false,
29
+ // `require_vote_quorum` and `allow_decline_over_approval` are NOT emitted
30
+ // here -- the canonical *-rules.schema.json commitment object closes with
31
+ // `additionalProperties: false` for every mode except Decision (issue
32
+ // #87 item 1, spec PR #125). Only decision-rules.schema.json's commitment
33
+ // object lists `require_vote_quorum`/`allow_decline_over_approval` as
34
+ // known keys; quorum/proposal/task/handoff-rules.schema.json's commitment
35
+ // object allows only `authority`/`designated_roles`. `buildDecisionPolicy`
36
+ // adds both fields itself, after calling this helper. Emitting them here
37
+ // unconditionally (as this SDK and python-sdk's `_commitment_dict` both
38
+ // used to, for byte-parity with each other rather than with the spec) put
39
+ // a key the other four modes' schemas do not recognize onto the wire --
40
+ // inert under today's runtime (which does not enforce
41
+ // `additionalProperties`) but non-conformant, and a real rejection under
42
+ // any stricter/future validator.
17
43
  };
18
44
  }
19
- function buildDecisionPolicy(policyId, description, rules) {
20
- // Decision-only: extend the shared v1 commitment rules with the schema_version 2
21
- // decline-over-approval switch, appended after the shared keys so it does not
22
- // leak into the still-v1 quorum/proposal/task/handoff commitment blocks
23
- // (parity with python-sdk `_commitment_dict` + `build_decision_policy`).
45
+ // Typed against VotingRules['algorithm'] (not a bare string[]) so adding a
46
+ // seventh canonical algorithm to that union without also adding it here is a
47
+ // tsc error instead of a silent runtime-only rejection — same frozen-set
48
+ // intent as this file's other guards (see e.g. commitment-hash-frozen-fields.test.ts).
49
+ const DECISION_ALGORITHMS = new Set([
50
+ 'none',
51
+ 'majority',
52
+ 'supermajority',
53
+ 'unanimous',
54
+ 'weighted',
55
+ 'plurality',
56
+ ]);
57
+ // RFC-MACP-0012 §8 requires runtimes to accept all three forever -- none of
58
+ // these is a deprecation path. Mirrors macp-sdk-python policy.py's
59
+ // _DECISION_SCHEMA_VERSIONS.
60
+ const DECISION_SCHEMA_VERSIONS = new Set([1, 2, 3]);
61
+ function buildDecisionPolicy(policyId, description, rules, options) {
62
+ const schemaVersion = options?.schemaVersion ?? 3;
63
+ if (!DECISION_SCHEMA_VERSIONS.has(schemaVersion)) {
64
+ throw new errors_1.MacpSessionError(`schemaVersion must be one of ${[...DECISION_SCHEMA_VERSIONS].sort().join(', ')}, got '${schemaVersion}'`);
65
+ }
66
+ const algorithm = rules.voting?.algorithm ?? 'none';
67
+ const threshold = rules.voting?.threshold ?? 0.5;
68
+ const weights = rules.voting?.weights;
69
+ // Match decision-rules.schema.json's constraints before the runtime does,
70
+ // same rationale as buildQuorumPolicy's threshold checks above: a bad
71
+ // descriptor fails immediately client-side instead of round-tripping to an
72
+ // INVALID_POLICY_DEFINITION from RegisterPolicy. Order matters and mirrors
73
+ // macp-sdk-python policy.py:148-180: algorithm enum, then threshold range,
74
+ // then the majority/supermajority asymmetry, then weighted-requires-weights,
75
+ // then the electorate rule (unconditional across every algorithm).
76
+ if (!DECISION_ALGORITHMS.has(algorithm)) {
77
+ throw new errors_1.MacpSessionError(`voting algorithm must be one of ${[...DECISION_ALGORITHMS].sort().join(', ')}, got '${algorithm}'`);
78
+ }
79
+ if (!(threshold > 0 && threshold <= 1)) {
80
+ throw new errors_1.MacpSessionError(`voting threshold must be > 0 and <= 1, got ${threshold}`);
81
+ }
82
+ if (algorithm === 'majority' && threshold < 0.5) {
83
+ throw new errors_1.MacpSessionError(`'majority' requires threshold >= 0.5 (an even split approves), got ${threshold}`);
84
+ }
85
+ // Deliberately asymmetric with the 'majority' check above: policy.std.majority
86
+ // sets threshold exactly 0.5 and RFC-MACP-0012 §2.2 pins that reserved profile
87
+ // byte-identical on every runtime, so an exclusive >= 0.5 bound here would
88
+ // refuse a profile the runtime pre-registers at startup. Do not "fix" this.
89
+ if (algorithm === 'supermajority' && threshold <= 0.5) {
90
+ throw new errors_1.MacpSessionError("'supermajority' requires threshold > 0.5 -- the field's own default of 0.5 is a bare majority " +
91
+ `wearing the name, got ${threshold}. Pass an explicit threshold (e.g. 0.67) for supermajority.`);
92
+ }
93
+ if (algorithm === 'weighted' && (!weights || Object.keys(weights).length === 0)) {
94
+ throw new errors_1.MacpSessionError("'weighted' algorithm requires a non-empty 'weights' map");
95
+ }
96
+ // The weighted electorate is meaningful only when non-empty and strictly
97
+ // positive-valued -- enforced unconditionally, at every algorithm, not only
98
+ // 'weighted' (decision-rules.schema.json: voting.weights minProperties 1,
99
+ // additionalProperties.exclusiveMinimum 0; normative at every schema_version).
100
+ // A weight-0 participant is expressed by omission from this map, never by an
101
+ // explicit 0. `weights !== undefined`, not a truthy check: `{}` is truthy.
102
+ if (weights !== undefined) {
103
+ if (Object.keys(weights).length === 0) {
104
+ throw new errors_1.MacpSessionError("'weights', if provided, must be non-empty");
105
+ }
106
+ for (const [participant, weight] of Object.entries(weights)) {
107
+ // weight <= 0 alone would let a NaN weight slip through (NaN <= 0 is
108
+ // false); the runtime guards this explicitly (registry.rs:596), mirrored
109
+ // here even though the Python reference does not check it separately.
110
+ if (weight <= 0 || Number.isNaN(weight)) {
111
+ throw new errors_1.MacpSessionError(`weights['${participant}'] must be > 0, got ${weight} -- a weight-0 observer is expressed by ` +
112
+ 'omission from the map, not by an explicit 0');
113
+ }
114
+ }
115
+ }
116
+ // Decision-only: extend the shared commitment rules with the two keys
117
+ // decision-rules.schema.json's commitment object allows and the other four
118
+ // modes' commitment schemas do not -- `require_vote_quorum` and the
119
+ // schema_version 2 decline-over-approval switch. Appended after the shared
120
+ // keys so neither leaks into the still-`additionalProperties: false`
121
+ // quorum/proposal/task/handoff commitment blocks (see serializeCommitment).
24
122
  const commitmentSection = serializeCommitment(rules.commitment);
123
+ commitmentSection.require_vote_quorum = rules.commitment?.requireVoteQuorum ?? false;
25
124
  commitmentSection.allow_decline_over_approval = rules.commitment?.allowDeclineOverApproval ?? false;
26
125
  const rulesJson = {
27
126
  voting: {
28
- algorithm: rules.voting?.algorithm ?? 'none',
29
- threshold: rules.voting?.threshold ?? 0.5,
127
+ algorithm,
128
+ threshold,
30
129
  quorum: rules.voting?.quorum ? { type: rules.voting.quorum.type, value: rules.voting.quorum.value } : undefined,
31
- weights: rules.voting?.weights ?? undefined,
130
+ weights,
32
131
  },
33
132
  objection_handling: {
34
133
  critical_severity_vetoes: rules.objectionHandling?.criticalSeverityVetoes ?? false,
@@ -46,22 +145,47 @@ function buildDecisionPolicy(policyId, description, rules) {
46
145
  mode: 'macp.mode.decision.v1',
47
146
  description,
48
147
  rules: JSON.stringify(rulesJson),
49
- schemaVersion: 2,
148
+ schemaVersion,
50
149
  };
51
150
  }
52
151
  function buildQuorumPolicy(policyId, description, rules) {
53
- if (rules.threshold?.type === 'percentage') {
54
- const v = rules.threshold.value;
55
- if (!Number.isInteger(v) || v < 0 || v > 100) {
56
- throw new errors_1.MacpSessionError(`quorum percentage threshold must be an integer in [0, 100] (e.g. 75 for 75%), got ${v}. ` +
57
- 'The runtime computes the approval bar as ceil(value/100 × participants); a fractional value ' +
58
- 'like 0.75 would round to a ~1% bar.');
152
+ const threshold = rules.threshold;
153
+ if (threshold) {
154
+ // Match the canonical quorum-rules schema's constraints before the runtime
155
+ // does, so a bad descriptor fails immediately client-side instead of
156
+ // round-tripping to an INVALID_POLICY_DEFINITION from RegisterPolicy.
157
+ // Order matters: integer first (a float reaching the range checks below
158
+ // would compare fine numerically but produce a confusing message), then
159
+ // > 0, then the percentage-specific <= 100 cap, then type. Mirrors
160
+ // macp-sdk-python policy.py:278-310.
161
+ //
162
+ // Number.isInteger(true) is false in TS, so unlike Python's
163
+ // isinstance(True, int) trap (policy.py:285-287), no separate bool guard
164
+ // is needed here.
165
+ if (!Number.isInteger(threshold.value)) {
166
+ throw new errors_1.MacpSessionError(`quorum threshold value must be an integer (e.g. 75 for 75%), got ${threshold.value}. ` +
167
+ "The canonical quorum-rules schema declares 'value' as an integer for every threshold " +
168
+ 'type; a fractional value like 0.75 would produce a schema-invalid descriptor that the ' +
169
+ 'runtime rejects at RegisterPolicy with worse diagnostics.');
170
+ }
171
+ if (threshold.value <= 0) {
172
+ throw new errors_1.MacpSessionError(`quorum threshold value must be > 0, got ${threshold.value}. A zero approval bar is ` +
173
+ 'trivially satisfied by any ballot set, so the canonical quorum-rules schema declares ' +
174
+ "'value' with exclusiveMinimum 0.");
175
+ }
176
+ if (threshold.type === 'percentage' && threshold.value > 100) {
177
+ throw new errors_1.MacpSessionError(`quorum threshold value must be 1-100 for type 'percentage', got ${threshold.value}`);
178
+ }
179
+ if (threshold.type !== 'n_of_m' && threshold.type !== 'percentage') {
180
+ throw new errors_1.MacpSessionError(`quorum threshold type must be 'n_of_m' or 'percentage', got ${JSON.stringify(threshold.type)}. ` +
181
+ "'weighted' is reserved -- it was removed from the canonical schema without ever having " +
182
+ 'defined semantics and must not be used.');
59
183
  }
60
184
  }
61
185
  const rulesJson = {
62
186
  threshold: {
63
187
  type: rules.threshold?.type ?? 'n_of_m',
64
- value: rules.threshold?.value ?? 0,
188
+ value: rules.threshold?.value ?? 1,
65
189
  },
66
190
  abstention: {
67
191
  counts_toward_quorum: rules.abstention?.countsTowardQuorum ?? false,
@@ -4,9 +4,11 @@ import type { Envelope } from '../types';
4
4
  * The kind of cardinality anomaly a projection recorded while replaying an
5
5
  * accepted transcript. Cross-SDK frozen contract (`macp-sdk-python` adopted
6
6
  * the identical field set, snake_case there) — do not add a kind without
7
- * cross-SDK agreement.
7
+ * cross-SDK agreement. `duplicate_task_accept`/`settled_handoff` were added
8
+ * under exactly that agreement (issue #126/#128; `macp-sdk-python` landed its
9
+ * half in PR #95, merged `32c6437`).
8
10
  */
9
- export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot';
11
+ export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot' | 'duplicate_task_accept' | 'settled_handoff';
10
12
  /**
11
13
  * A recorded observation that a second distinct `Vote` from this sender for
12
14
  * this proposal (RFC-MACP-0007 §5.3), or a second distinct ballot across
@@ -31,7 +33,8 @@ export type ProjectionAnomalyKind = 'duplicate_vote' | 'duplicate_ballot';
31
33
  * `macp-sdk-python`). Do not add, rename, or remove a field without
32
34
  * cross-SDK agreement.
33
35
  *
34
- * Only `DecisionProjection` and `QuorumProjection` populate this today.
36
+ * `DecisionProjection`, `QuorumProjection`, `TaskProjection`, and
37
+ * `HandoffProjection` populate this today.
35
38
  */
36
39
  export interface ProjectionAnomaly {
37
40
  kind: ProjectionAnomalyKind;
@@ -39,7 +42,10 @@ export interface ProjectionAnomaly {
39
42
  messageType: string;
40
43
  messageId: string;
41
44
  sender: string;
42
- /** `proposal_id` (Decision) or `request_id` (Quorum) the duplicate targeted. */
45
+ /**
46
+ * `proposal_id` (Decision), `request_id` (Quorum), `task_id` (Task), or
47
+ * `handoff_id` (Handoff) the anomaly targeted.
48
+ */
43
49
  subjectId: string;
44
50
  /**
45
51
  * Human-readable detail. For a cross-type Quorum duplicate, `messageType`
@@ -48,12 +54,54 @@ export interface ProjectionAnomaly {
48
54
  */
49
55
  detail: string;
50
56
  }
57
+ /**
58
+ * The four `ProjectionAnomalyKind` values as named constants — all four are
59
+ * cross-SDK contract, literally pinned by the spec repo's
60
+ * `schemas/parity/contract.json` (`projection_anomaly.kinds`, `contract_version`
61
+ * 1.2.0 — `ANOMALY_DUPLICATE_TASK_ACCEPT`/`ANOMALY_SETTLED_HANDOFF` landed there
62
+ * under issue #126/#128, after this SDK's PR #134 completed the agreement
63
+ * `macp-sdk-python`'s PR #95 started). `satisfies` links each constant to the
64
+ * union above at compile time, so a drifted value fails `npm run check` before
65
+ * it can ever reach `make verify-parity`.
66
+ */
67
+ export declare const ANOMALY_DUPLICATE_VOTE = "duplicate_vote";
68
+ export declare const ANOMALY_DUPLICATE_BALLOT = "duplicate_ballot";
69
+ export declare const ANOMALY_DUPLICATE_TASK_ACCEPT = "duplicate_task_accept";
70
+ export declare const ANOMALY_SETTLED_HANDOFF = "settled_handoff";
71
+ /**
72
+ * Runtime field order for `ProjectionAnomaly`, with the same frozen-set
73
+ * guarantee as `_ProjectionAnomalyFieldSetIsFrozen` above, expressed as a
74
+ * value rather than only a type. The spec repo's
75
+ * `schemas/parity/contract.json` (`projection_anomaly.fields`) pins this
76
+ * field order as a runtime JSON array, so this SDK needs its own runtime
77
+ * array to assert against it (see `tests/parity/contract.test.ts`).
78
+ *
79
+ * **Public, not hidden — corrected during Phase 2's own verification round.**
80
+ * An earlier revision of this docblock claimed this constant was
81
+ * "deliberately NOT added to the public barrel," on the (wrong) assumption
82
+ * that it would follow `canonicalizeCommitmentPayload`'s precedent in
83
+ * `commitment-hash.ts`. That precedent doesn't transfer: `commitment-hash.ts`
84
+ * is barrel-exported via a *named* line in `src/index.ts`
85
+ * (`export { commitmentHash, isCanonicalCommitmentHash } from
86
+ * './commitment-hash'`), which can omit a name on purpose. This module is
87
+ * barrel-exported via `src/projections.ts`'s `export * from
88
+ * './projections/base'` → `src/index.ts`'s `export * from './projections'`
89
+ * — a wildcard re-export chain has no way to omit one name, so this constant
90
+ * reaches `dist/index.d.ts` and the runtime `require('macp-sdk-typescript')`
91
+ * surface regardless of intent (verified: `node -e "require('./dist/index.js')
92
+ * .PROJECTION_ANOMALY_FIELD_ORDER"` prints the array). This is harmless —
93
+ * a small readonly string tuple is no more a surface-area risk than
94
+ * `STANDARD_MODES` — so the fix is documenting reality, not restructuring
95
+ * the export chain to force exclusion. Phase 3's public-API snapshot
96
+ * correctly captures this symbol as part of today's real surface.
97
+ */
98
+ export declare const PROJECTION_ANOMALY_FIELD_ORDER: readonly ["kind", "mode", "messageType", "messageId", "sender", "subjectId", "detail"];
51
99
  /**
52
100
  * Abstract base for in-process mode-state tracking — parity with python-sdk's
53
101
  * `macp_sdk.base_projection.BaseProjection`. Maintains a shared transcript,
54
102
  * phase string, and commitment payload; subclasses override `applyMode`
55
- * to handle mode-specific envelopes. `SessionStart` and `Commitment` are
56
- * handled here so custom modes get the common lifecycle for free.
103
+ * to handle mode-specific envelopes. `Commitment` is handled here so custom
104
+ * modes get the common lifecycle for free.
57
105
  */
58
106
  export declare abstract class BaseProjection {
59
107
  /**
@@ -84,19 +132,28 @@ export declare abstract class BaseProjection {
84
132
  /**
85
133
  * Cardinality anomalies recorded while replaying this projection's
86
134
  * accepted transcript (e.g. a duplicate vote or ballot from the same
87
- * sender). See `ProjectionAnomaly`. Empty unless a subclass calls
88
- * `recordAnomaly`. NOTE: as of this SDK's built-in modes, nothing calls
89
- * `recordAnomaly` — `DecisionProjection` and `QuorumProjection` do not
90
- * extend `BaseProjection` (see the class docblock) and inline their own
91
- * two lines instead. This field and `recordAnomaly` exist for ext-mode
92
- * `BaseProjection` subclasses outside this repo.
135
+ * sender, a duplicate `TaskAccept` competing for an already-held assignee
136
+ * slot, or a `HandoffAccept`/`HandoffDecline` for an already-settled
137
+ * handoff). See `ProjectionAnomaly`. Empty unless a subclass calls
138
+ * `recordAnomaly`. **Corrected 2026-09-25** (this note previously said
139
+ * nothing calls `recordAnomaly` because `DecisionProjection`/
140
+ * `QuorumProjection` didn't extend `BaseProjection` — that was true before
141
+ * issue #91's refactor, and is stale now): both `DecisionProjection`
142
+ * (`decision.ts`) and `QuorumProjection` (`quorum.ts`) extend
143
+ * `BaseProjection` today and DO call the inherited `recordAnomaly` when
144
+ * they discard a duplicate vote/ballot — they just pass a literal
145
+ * `'duplicate_vote'`/`'duplicate_ballot'` string for `kind` rather than
146
+ * this file's `ANOMALY_DUPLICATE_VOTE`/`ANOMALY_DUPLICATE_BALLOT`
147
+ * constants (referencing those constants instead is optional cleanup, not
148
+ * required by anything in this repo). This field and `recordAnomaly` are
149
+ * no longer ext-mode-only — any subclass, built-in or ext-mode, uses them.
93
150
  */
94
151
  readonly anomalies: ProjectionAnomaly[];
95
152
  protected abstract readonly mode: string;
96
153
  /**
97
154
  * True once at least one `ProjectionAnomaly` has been recorded — i.e. this
98
- * projection observed and discarded a duplicate vote or ballot from the
99
- * same sender (see `anomalies` above). This getter is exactly
155
+ * projection observed and discarded a cardinality anomaly (see `anomalies`
156
+ * above for the full set of kinds). This getter is exactly
100
157
  * `anomalies.length > 0`; it never asserts "this transcript violates the
101
158
  * spec" (see `ProjectionAnomaly`'s docblock, and `applyEnvelope`'s "Input
102
159
  * contract: accepted-only" section above, for why a projection cannot make
@@ -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
@@ -125,6 +184,7 @@ class BaseProjection {
125
184
  // must apply WITHOUT dedup, or a feed of empty-id envelopes collapses to
126
185
  // one. This is architecturally expected (RFC-MACP-0001:306 at-least-once
127
186
  // delivery), not an anomaly — hence `debug`, never `warn`.
187
+ let seenIdAdded = false;
128
188
  if (envelope.messageId) {
129
189
  if (this.seenMessageIds.has(envelope.messageId)) {
130
190
  logging_1.logger.debug('projection redelivery ignored', {
@@ -135,14 +195,44 @@ class BaseProjection {
135
195
  return;
136
196
  }
137
197
  this.seenMessageIds.add(envelope.messageId);
198
+ seenIdAdded = true;
138
199
  }
139
200
  this.transcript.push(envelope);
140
- if (envelope.messageType === 'Commitment') {
141
- this.commitment = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
142
- this.phase = 'Committed';
143
- return;
201
+ // Rollback invariant (parity with macp-sdk-python's base_projection.py
202
+ // apply_envelope): the only fallible operation below is the payload
203
+ // decode. If it throws, `transcript`/`seenMessageIds` must not retain a
204
+ // partial application of this envelope, or a legitimate retry of the
205
+ // same `message_id` is silently swallowed as a redelivery and the
206
+ // envelope's effect is lost forever. Deliberately narrow -- this rolls
207
+ // back only the two mutations made above, never mode/subclass-owned
208
+ // state, which is safe only because every projection performs its one
209
+ // fallible operation (decode) strictly before any state mutation.
210
+ try {
211
+ if (envelope.messageType === 'Commitment') {
212
+ this.commitment = protoRegistry.decodeKnownPayload(envelope.mode, envelope.messageType, envelope.payload);
213
+ this.phase = 'Committed';
214
+ return;
215
+ }
216
+ this.applyMode(envelope, protoRegistry);
217
+ }
218
+ catch (err) {
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();
232
+ if (seenIdAdded)
233
+ this.seenMessageIds.delete(envelope.messageId);
234
+ throw err;
144
235
  }
145
- this.applyMode(envelope, protoRegistry);
146
236
  }
147
237
  /**
148
238
  * Record a cardinality anomaly: push it onto `anomalies` and emit it via
@@ -1,4 +1,4 @@
1
- import type { ProjectionAnomaly } from './base';
1
+ import { BaseProjection } from './base';
2
2
  import type { Envelope } from '../types';
3
3
  import type { ProtoRegistry } from '../proto-registry';
4
4
  export interface DecisionProposalRecord {
@@ -26,59 +26,15 @@ export interface DecisionVoteRecord {
26
26
  reason?: string;
27
27
  sender: string;
28
28
  }
29
- export declare class DecisionProjection {
29
+ export declare class DecisionProjection extends BaseProjection {
30
+ protected readonly mode = "macp.mode.decision.v1";
30
31
  readonly proposals: Map<string, DecisionProposalRecord>;
31
32
  readonly evaluations: DecisionEvaluationRecord[];
32
33
  readonly objections: DecisionObjectionRecord[];
33
34
  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
- */
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[];
49
35
  phase: 'Proposal' | 'Evaluation' | 'Voting' | 'Committed';
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
- */
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;
80
- get isCommitted(): boolean;
81
- get isPositiveOutcome(): boolean | undefined;
36
+ /** Handle a Decision-mode envelope. See `BaseProjection.applyEnvelope` for the accepted-only input contract and redelivery/rollback semantics shared by all built-in modes. */
37
+ protected applyMode(envelope: Envelope, protoRegistry: ProtoRegistry): void;
82
38
  voteTotals(): Record<string, number>;
83
39
  majorityWinner(): string | undefined;
84
40
  /** Returns the APPROVE vote ratio excluding ABSTAIN votes from the denominator. */