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.
- package/CHANGELOG.md +465 -0
- package/README.md +2 -2
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +2 -1
- package/dist/agent/participant.d.ts +47 -4
- package/dist/agent/participant.js +88 -10
- package/dist/agent/strategies.js +23 -7
- package/dist/agent/transports.d.ts +19 -1
- package/dist/agent/transports.js +108 -66
- package/dist/auth.d.ts +15 -4
- package/dist/auth.js +23 -7
- package/dist/base-session.js +13 -3
- package/dist/client.js +58 -15
- package/dist/commitment-hash.d.ts +33 -3
- package/dist/commitment-hash.js +39 -5
- package/dist/decision.d.ts +1 -0
- package/dist/decision.js +11 -9
- package/dist/envelope.js +9 -1
- package/dist/handoff.d.ts +1 -0
- package/dist/handoff.js +19 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +18 -7
- package/dist/policy.d.ts +54 -7
- package/dist/policy.js +143 -19
- package/dist/projections/base.d.ts +71 -14
- package/dist/projections/base.js +106 -16
- package/dist/projections/decision.d.ts +5 -49
- package/dist/projections/decision.js +30 -85
- package/dist/projections/handoff.d.ts +5 -48
- package/dist/projections/handoff.js +44 -93
- package/dist/projections/proposal.d.ts +5 -48
- package/dist/projections/proposal.js +21 -79
- package/dist/projections/quorum.d.ts +5 -49
- package/dist/projections/quorum.js +20 -82
- package/dist/projections/task.d.ts +31 -51
- package/dist/projections/task.js +49 -90
- package/dist/proposal.d.ts +1 -0
- package/dist/proposal.js +2 -2
- package/dist/proto-registry.d.ts +85 -5
- package/dist/proto-registry.js +101 -15
- package/dist/quorum.d.ts +1 -0
- package/dist/quorum.js +4 -4
- package/dist/task.d.ts +1 -0
- package/dist/task.js +24 -11
- package/dist/types.d.ts +14 -7
- package/dist/validation.d.ts +14 -0
- package/dist/validation.js +39 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
29
|
-
threshold
|
|
127
|
+
algorithm,
|
|
128
|
+
threshold,
|
|
30
129
|
quorum: rules.voting?.quorum ? { type: rules.voting.quorum.type, value: rules.voting.quorum.value } : undefined,
|
|
31
|
-
weights
|
|
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
|
|
148
|
+
schemaVersion,
|
|
50
149
|
};
|
|
51
150
|
}
|
|
52
151
|
function buildQuorumPolicy(policyId, description, rules) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 ??
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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. `
|
|
56
|
-
*
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* `BaseProjection`
|
|
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
|
|
99
|
-
*
|
|
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
|
package/dist/projections/base.js
CHANGED
|
@@ -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. `
|
|
10
|
-
*
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `BaseProjection`
|
|
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
|
|
52
|
-
*
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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. */
|