@taasky/protocol 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,32 @@
1
+ export declare const PROTOCOL_VERSION = 1;
2
+ export declare const VERDICTS: readonly ["offer", "counter", "need_more_detail", "approve", "reject", "cancel"];
3
+ export type Verdict = (typeof VERDICTS)[number];
4
+ /** Moves that pass the turn back to the other side without ending the room. */
5
+ export declare const CONTINUING: ReadonlySet<Verdict>;
6
+ /** Moves only a human can release; the agent's request for one waits for them (§8). */
7
+ export declare const HUMAN_GATED: readonly ["approve", "reject", "cancel"];
8
+ export type HumanGatedVerdict = (typeof HUMAN_GATED)[number];
9
+ export declare function isHumanGated(v: Verdict): v is HumanGatedVerdict;
10
+ /** Moves that carry terms, and therefore must carry a subject hash. */
11
+ export declare const CARRIES_TERMS: ReadonlySet<Verdict>;
12
+ export interface Envelope {
13
+ v: number;
14
+ room: string;
15
+ msg: string;
16
+ from: string;
17
+ round: number;
18
+ verdict: Verdict;
19
+ in_reply_to: string | null;
20
+ subject_hash: string | null;
21
+ deadline: string | null;
22
+ ts: string;
23
+ subject: Record<string, unknown> | null;
24
+ }
25
+ export declare function isVerdict(x: unknown): x is Verdict;
26
+ /**
27
+ * Deterministic serialisation for hashing: keys sorted at every level, so two
28
+ * sides hash identical terms identically. Without this `subject_hash` cannot
29
+ * be compared across parties and "we agreed" stays an agent's claim (§3).
30
+ */
31
+ export declare function canonicalize(value: unknown): string;
32
+ export declare function subjectHash(subject: unknown): Promise<string>;
@@ -0,0 +1,48 @@
1
+ // The typed half of the protocol (SPEC.md §3). Closed on purpose:
2
+ // the state machine hangs off `verdict`, so it does not grow. `subject` does.
3
+ export const PROTOCOL_VERSION = 1;
4
+ export const VERDICTS = [
5
+ "offer",
6
+ "counter",
7
+ "need_more_detail",
8
+ "approve",
9
+ "reject",
10
+ "cancel",
11
+ ];
12
+ /** Moves that pass the turn back to the other side without ending the room. */
13
+ export const CONTINUING = new Set([
14
+ "offer",
15
+ "counter",
16
+ "need_more_detail",
17
+ ]);
18
+ /** Moves only a human can release; the agent's request for one waits for them (§8). */
19
+ export const HUMAN_GATED = ["approve", "reject", "cancel"];
20
+ export function isHumanGated(v) {
21
+ return HUMAN_GATED.includes(v);
22
+ }
23
+ /** Moves that carry terms, and therefore must carry a subject hash. */
24
+ export const CARRIES_TERMS = new Set(["offer", "counter"]);
25
+ export function isVerdict(x) {
26
+ return typeof x === "string" && VERDICTS.includes(x);
27
+ }
28
+ /**
29
+ * Deterministic serialisation for hashing: keys sorted at every level, so two
30
+ * sides hash identical terms identically. Without this `subject_hash` cannot
31
+ * be compared across parties and "we agreed" stays an agent's claim (§3).
32
+ */
33
+ export function canonicalize(value) {
34
+ if (value === null || typeof value !== "object")
35
+ return JSON.stringify(value);
36
+ if (Array.isArray(value))
37
+ return `[${value.map(canonicalize).join(",")}]`;
38
+ const entries = Object.entries(value)
39
+ .filter(([, v]) => v !== undefined)
40
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
41
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
42
+ }
43
+ export async function subjectHash(subject) {
44
+ const bytes = new TextEncoder().encode(canonicalize(subject));
45
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
46
+ const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
47
+ return `sha256:${hex}`;
48
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./envelope.js";
2
+ export * from "./limits.js";
3
+ export * from "./move.js";
4
+ export * from "./state.js";
5
+ export * from "./subject.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // The reference implementation of the Taasky Negotiation Protocol (SPEC.md).
2
+ // Pure functions over plain data: no storage, no transport, no clock of its
3
+ // own. A relay loads a room, asks what a move does, and stores the answer.
4
+ export * from "./envelope.js";
5
+ export * from "./limits.js";
6
+ export * from "./move.js";
7
+ export * from "./state.js";
8
+ export * from "./subject.js";
@@ -0,0 +1,38 @@
1
+ import { type SubjectSchema } from "./subject.js";
2
+ export interface FieldLimit {
3
+ /** int: integer; money: {amount, currency}; date: YYYY-MM-DD; timerange: RFC3339, no start before it. */
4
+ min?: unknown;
5
+ /** As min; timerange: no end after it. */
6
+ max?: unknown;
7
+ /** enum: the values that are acceptable. */
8
+ one_of?: string[];
9
+ /** bool: the only acceptable value. */
10
+ equals?: boolean;
11
+ }
12
+ export type Limits = Record<string, FieldLimit>;
13
+ /** One way a value falls outside a limit. `bound` is shaped like the field's values. */
14
+ export interface Breach {
15
+ field: string;
16
+ rule: "below_min" | "above_max" | "not_allowed" | "not_equal" | "other_currency";
17
+ bound: unknown;
18
+ }
19
+ /** Validates limits against the party's own schema. Unknown fields and loose shapes are refused. */
20
+ export declare function parseLimits(schema: SubjectSchema, raw: unknown): Limits | null;
21
+ /**
22
+ * Where `subject` falls outside `limits`. `subject` may be in the other
23
+ * party's shape: a field is checked only where both declare it with the same
24
+ * type, since that is the only case where "the same field" means anything.
25
+ */
26
+ export declare function checkLimits(schema: SubjectSchema, limits: Limits | null, subject: Record<string, unknown>, subjectSchema?: SubjectSchema): Breach[];
27
+ /**
28
+ * The fields on which two parties' limits cannot both be met — empty when
29
+ * they can, or when nobody limited the same field. Answers yes or no per
30
+ * field and never says by how much: that is all either side learns.
31
+ */
32
+ export declare function cannotMeet(a: {
33
+ schema: SubjectSchema;
34
+ limits: Limits | null;
35
+ }, b: {
36
+ schema: SubjectSchema;
37
+ limits: Limits | null;
38
+ }): string[];
package/dist/limits.js ADDED
@@ -0,0 +1,214 @@
1
+ // Private limits (SPEC.md §7): the bounds a party's human set — the most
2
+ // they pay, the earliest they can meet. Only the relay sees them; the other
3
+ // side never does. They do three things the agents cannot be trusted with,
4
+ // because an agent tuned to be agreeable drifts toward any deal:
5
+ //
6
+ // - an agent's own offer outside its limits is refused before it is sent;
7
+ // - terms outside them are flagged on the human's confirmation card;
8
+ // - when both sides set limits on the same field and they cannot meet, both
9
+ // are told so — without either number — instead of circling forever.
10
+ //
11
+ // Limits are set once, on open or join, and never change: a party that could
12
+ // move its limit and ask again could find the other's number one bit at a time.
13
+ import { minorDigits, SubjectError, } from "./subject.js";
14
+ function fail(path, why) {
15
+ throw new SubjectError(`${path}: ${why}`);
16
+ }
17
+ const isDate = (v) => typeof v === "string" &&
18
+ /^\d{4}-\d{2}-\d{2}$/.test(v) &&
19
+ new Date(`${v}T00:00:00Z`).toISOString().startsWith(v);
20
+ /** A money amount as an integer count of thousandths, so no float decides a comparison. */
21
+ function milli(amount) {
22
+ const [whole, frac = ""] = amount.split(".");
23
+ return BigInt(whole + frac.padEnd(3, "0"));
24
+ }
25
+ function parseBound(decl, value, at) {
26
+ switch (decl.type) {
27
+ case "int":
28
+ if (typeof value !== "number" || !Number.isSafeInteger(value))
29
+ fail(at, "expected integer");
30
+ return value;
31
+ case "date":
32
+ if (!isDate(value))
33
+ fail(at, "expected a date, YYYY-MM-DD");
34
+ return value;
35
+ case "timerange":
36
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
37
+ fail(at, "expected an RFC3339 time");
38
+ }
39
+ return value;
40
+ case "money": {
41
+ const m = value;
42
+ if (!m || typeof m !== "object" || typeof m.currency !== "string") {
43
+ fail(at, "expected {amount, currency}");
44
+ }
45
+ if (!/^[A-Z]{3}$/.test(m.currency))
46
+ fail(at, "currency must be ISO 4217");
47
+ if (decl.currency && m.currency !== decl.currency)
48
+ fail(at, `currency must be ${decl.currency}`);
49
+ const digits = minorDigits(m.currency);
50
+ const shape = digits ? `^\\d{1,15}(\\.\\d{1,${digits}})?$` : "^\\d{1,15}$";
51
+ if (typeof m.amount !== "string" || !new RegExp(shape).test(m.amount)) {
52
+ fail(at, "amount must be a decimal string");
53
+ }
54
+ return { amount: m.amount, currency: m.currency };
55
+ }
56
+ default:
57
+ fail(at, `min and max do not apply to ${decl.type} fields`);
58
+ }
59
+ }
60
+ /** -1, 0 or 1. Money bounds of different currencies never get here. */
61
+ function compare(type, a, b) {
62
+ let d;
63
+ if (type === "money") {
64
+ d = milli(a.amount) - milli(b.amount);
65
+ }
66
+ else if (type === "timerange")
67
+ d = Date.parse(a) - Date.parse(b);
68
+ else if (type === "int")
69
+ d = a - b;
70
+ else
71
+ d = a < b ? -1 : a > b ? 1 : 0;
72
+ return d > 0 ? 1 : d < 0 ? -1 : 0;
73
+ }
74
+ const currencyOf = (v) => v?.currency;
75
+ /** Validates limits against the party's own schema. Unknown fields and loose shapes are refused. */
76
+ export function parseLimits(schema, raw) {
77
+ if (raw === undefined || raw === null)
78
+ return null;
79
+ if (typeof raw !== "object" || Array.isArray(raw))
80
+ fail("limits", "must be an object by field");
81
+ const limits = {};
82
+ for (const [field, value] of Object.entries(raw)) {
83
+ const at = `limits.${field}`;
84
+ const decl = schema[field];
85
+ if (!decl)
86
+ fail(at, "not a field of your subject_schema");
87
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
88
+ fail(at, "must be an object");
89
+ }
90
+ const v = value;
91
+ const keys = Object.keys(v);
92
+ const allowed = decl.type === "enum" ? ["one_of"] : decl.type === "bool" ? ["equals"] : ["min", "max"];
93
+ if (decl.type === "string")
94
+ fail(at, "string fields take no limits");
95
+ if (keys.length === 0 || !keys.every((k) => allowed.includes(k))) {
96
+ fail(at, `${decl.type} fields take ${allowed.join(" and/or ")}`);
97
+ }
98
+ const limit = {};
99
+ if (decl.type === "enum") {
100
+ const one = v.one_of;
101
+ if (!Array.isArray(one) || one.length === 0 || !one.every((x) => decl.values.includes(x))) {
102
+ fail(at, "one_of must list values of the field");
103
+ }
104
+ limit.one_of = [...new Set(one)];
105
+ }
106
+ else if (decl.type === "bool") {
107
+ if (typeof v.equals !== "boolean")
108
+ fail(at, "equals must be true or false");
109
+ limit.equals = v.equals;
110
+ }
111
+ else {
112
+ if ("min" in v)
113
+ limit.min = parseBound(decl, v.min, `${at}.min`);
114
+ if ("max" in v)
115
+ limit.max = parseBound(decl, v.max, `${at}.max`);
116
+ if (limit.min !== undefined && limit.max !== undefined) {
117
+ if (decl.type === "money" && currencyOf(limit.min) !== currencyOf(limit.max)) {
118
+ fail(at, "min and max must be in the same currency");
119
+ }
120
+ if (compare(decl.type, limit.min, limit.max) > 0)
121
+ fail(at, "min is above max");
122
+ }
123
+ }
124
+ limits[field] = limit;
125
+ }
126
+ return Object.keys(limits).length ? limits : null;
127
+ }
128
+ function breachesOf(type, field, limit, value) {
129
+ if (Array.isArray(value))
130
+ return value.flatMap((v) => breachesOf(type, field, limit, v));
131
+ if (type === "enum") {
132
+ return limit.one_of && !limit.one_of.includes(value)
133
+ ? [{ field, rule: "not_allowed", bound: limit.one_of }]
134
+ : [];
135
+ }
136
+ if (type === "bool") {
137
+ return limit.equals !== undefined && value !== limit.equals
138
+ ? [{ field, rule: "not_equal", bound: limit.equals }]
139
+ : [];
140
+ }
141
+ const out = [];
142
+ const bound = limit.min ?? limit.max;
143
+ if (type === "money" && bound !== undefined && currencyOf(bound) !== currencyOf(value)) {
144
+ return [{ field, rule: "other_currency", bound }];
145
+ }
146
+ // A meeting must lie inside the window: its start no earlier than min,
147
+ // its end no later than max.
148
+ const low = type === "timerange" ? value.start : value;
149
+ const high = type === "timerange" ? value.end : value;
150
+ if (limit.min !== undefined && compare(type, low, limit.min) < 0) {
151
+ out.push({ field, rule: "below_min", bound: limit.min });
152
+ }
153
+ if (limit.max !== undefined && compare(type, high, limit.max) > 0) {
154
+ out.push({ field, rule: "above_max", bound: limit.max });
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Where `subject` falls outside `limits`. `subject` may be in the other
160
+ * party's shape: a field is checked only where both declare it with the same
161
+ * type, since that is the only case where "the same field" means anything.
162
+ */
163
+ export function checkLimits(schema, limits, subject, subjectSchema = schema) {
164
+ if (!limits)
165
+ return [];
166
+ return Object.entries(limits).flatMap(([field, limit]) => {
167
+ const value = subject[field];
168
+ const type = schema[field]?.type;
169
+ if (value === undefined || value === null || !type || subjectSchema[field]?.type !== type) {
170
+ return [];
171
+ }
172
+ return breachesOf(type, field, limit, value);
173
+ });
174
+ }
175
+ /**
176
+ * The fields on which two parties' limits cannot both be met — empty when
177
+ * they can, or when nobody limited the same field. Answers yes or no per
178
+ * field and never says by how much: that is all either side learns.
179
+ */
180
+ export function cannotMeet(a, b) {
181
+ if (!a.limits || !b.limits)
182
+ return [];
183
+ const fields = [];
184
+ for (const [field, la] of Object.entries(a.limits)) {
185
+ const lb = b.limits[field];
186
+ const type = a.schema[field]?.type;
187
+ if (!lb || !type || b.schema[field]?.type !== type)
188
+ continue;
189
+ let apart = false;
190
+ if (type === "enum") {
191
+ apart = !!la.one_of && !!lb.one_of && !la.one_of.some((v) => lb.one_of.includes(v));
192
+ }
193
+ else if (type === "bool") {
194
+ apart = la.equals !== undefined && lb.equals !== undefined && la.equals !== lb.equals;
195
+ }
196
+ else {
197
+ const bounds = [la.min, la.max, lb.min, lb.max].filter((x) => x !== undefined);
198
+ // Amounts in two currencies are not comparable; say nothing rather than guess.
199
+ if (type === "money" && new Set(bounds.map(currencyOf)).size > 1)
200
+ continue;
201
+ const pick = (x, y, sign) => x === undefined ? y : y === undefined ? x : compare(type, x, y) * sign >= 0 ? x : y;
202
+ const low = pick(la.min, lb.min, 1);
203
+ const high = pick(la.max, lb.max, -1);
204
+ if (low !== undefined && high !== undefined) {
205
+ const c = compare(type, low, high);
206
+ // A meeting needs time between start and end, so an exact touch is no window.
207
+ apart = type === "timerange" ? c >= 0 : c > 0;
208
+ }
209
+ }
210
+ if (apart)
211
+ fields.push(field);
212
+ }
213
+ return fields;
214
+ }
package/dist/move.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type Verdict } from "./envelope.js";
2
+ import { type RoomView } from "./state.js";
3
+ import { type SubjectSchema } from "./subject.js";
4
+ export interface MoveParty {
5
+ id: string;
6
+ subject_schema: SubjectSchema;
7
+ }
8
+ export interface MoveHistoryItem {
9
+ id: string;
10
+ seq: number;
11
+ from_id: string;
12
+ verdict: string;
13
+ subject_hash: string | null;
14
+ }
15
+ export interface MoveRequest {
16
+ actor: string;
17
+ verdict: Verdict;
18
+ /** offer / counter only. */
19
+ subject?: unknown;
20
+ /** approve only: the hash of the terms being approved, as the actor read them. */
21
+ subject_hash?: string;
22
+ deadline?: string | null;
23
+ }
24
+ export interface MovePlan {
25
+ message: {
26
+ verdict: Verdict;
27
+ in_reply_to: string | null;
28
+ subject_hash: string | null;
29
+ subject: Record<string, unknown> | null;
30
+ deadline: string | null;
31
+ };
32
+ next: Omit<RoomView, "id">;
33
+ }
34
+ /** How many moves in a row without new terms count as going in circles. */
35
+ export declare const CIRCLE_WINDOW = 4;
36
+ /**
37
+ * The room is going in circles: the last CIRCLE_WINDOW moves were all
38
+ * counters or requests for detail, and none of them tabled terms that were
39
+ * not on the table before — two sides restating their positions, or asking
40
+ * each other the same thing. Two agents whose humans' bounds never meet do
41
+ * this forever, and neither can say so without giving its bound away.
42
+ * Tiny concessions (100 → 99.99) are new terms and do not count.
43
+ */
44
+ export declare function isCircling<T extends MoveHistoryItem>(history: readonly T[]): boolean;
45
+ /** The last move that put terms on the table, if any. */
46
+ export declare function termsOnTable<T extends MoveHistoryItem>(history: readonly T[]): T | null;
47
+ export declare function planMove(room: RoomView, parties: readonly MoveParty[], history: readonly MoveHistoryItem[], req: MoveRequest, now: Date): Promise<MovePlan>;
package/dist/move.js ADDED
@@ -0,0 +1,118 @@
1
+ // What a move does to a room (SPEC.md §3, §5), decided without touching
2
+ // storage. The caller loads the room, asks for a plan, and writes it only if
3
+ // the room has not changed in between.
4
+ import { CARRIES_TERMS, subjectHash } from "./envelope.js";
5
+ import { assertMoveAllowed, MoveError, nextState } from "./state.js";
6
+ import { validateSubject } from "./subject.js";
7
+ /** How many moves in a row without new terms count as going in circles. */
8
+ export const CIRCLE_WINDOW = 4;
9
+ /**
10
+ * The room is going in circles: the last CIRCLE_WINDOW moves were all
11
+ * counters or requests for detail, and none of them tabled terms that were
12
+ * not on the table before — two sides restating their positions, or asking
13
+ * each other the same thing. Two agents whose humans' bounds never meet do
14
+ * this forever, and neither can say so without giving its bound away.
15
+ * Tiny concessions (100 → 99.99) are new terms and do not count.
16
+ */
17
+ export function isCircling(history) {
18
+ const recent = history.slice(-CIRCLE_WINDOW);
19
+ if (recent.length < CIRCLE_WINDOW)
20
+ return false;
21
+ if (!recent.every((m) => m.verdict === "counter" || m.verdict === "need_more_detail")) {
22
+ return false;
23
+ }
24
+ const seen = new Set(history.slice(0, -CIRCLE_WINDOW).map((m) => m.subject_hash));
25
+ return recent.every((m) => !m.subject_hash || seen.has(m.subject_hash));
26
+ }
27
+ /** The last move that put terms on the table, if any. */
28
+ export function termsOnTable(history) {
29
+ for (let i = history.length - 1; i >= 0; i--) {
30
+ if (CARRIES_TERMS.has(history[i].verdict))
31
+ return history[i];
32
+ }
33
+ return null;
34
+ }
35
+ export async function planMove(room, parties, history, req, now) {
36
+ const actor = parties.find((p) => p.id === req.actor);
37
+ if (!actor)
38
+ throw new MoveError("you do not take part in this room");
39
+ assertMoveAllowed(room, req.actor, req.verdict, now);
40
+ const deadline = req.deadline ?? null;
41
+ if (deadline !== null) {
42
+ const at = Date.parse(deadline);
43
+ if (Number.isNaN(at))
44
+ throw new MoveError("deadline must be RFC3339");
45
+ if (at <= now.getTime())
46
+ throw new MoveError("deadline must be in the future");
47
+ }
48
+ const terms = termsOnTable(history);
49
+ const last = history.at(-1) ?? null;
50
+ const carriesTerms = CARRIES_TERMS.has(req.verdict);
51
+ if (!carriesTerms && req.subject !== undefined) {
52
+ throw new MoveError(`${req.verdict} carries no terms; leave subject out`);
53
+ }
54
+ if (req.verdict !== "approve" && req.subject_hash !== undefined) {
55
+ throw new MoveError("subject_hash is only given with approve");
56
+ }
57
+ let subject = null;
58
+ let hash = null;
59
+ let inReplyTo = last?.id ?? null;
60
+ let bothApproved = false;
61
+ switch (req.verdict) {
62
+ case "offer":
63
+ case "counter":
64
+ if (req.verdict === "offer" && terms) {
65
+ throw new MoveError("terms are already on the table; answer them with counter");
66
+ }
67
+ if (req.verdict === "counter" && !terms) {
68
+ throw new MoveError("nothing to counter yet; open with offer");
69
+ }
70
+ if (req.subject === undefined)
71
+ throw new MoveError(`${req.verdict} needs a subject`);
72
+ // A party's terms conform to the shape that party declared (§4).
73
+ subject = validateSubject(actor.subject_schema, req.subject);
74
+ hash = await subjectHash(subject);
75
+ break;
76
+ case "need_more_detail":
77
+ if (!terms)
78
+ throw new MoveError("nothing to ask about yet; open with offer");
79
+ break;
80
+ case "approve": {
81
+ if (!terms)
82
+ throw new MoveError("no terms on the table to approve");
83
+ if (req.subject_hash === undefined) {
84
+ throw new MoveError("approve must name the subject_hash of the terms it accepts");
85
+ }
86
+ if (req.subject_hash !== terms.subject_hash) {
87
+ throw new MoveError("subject_hash does not match the terms on the table; re-read the room");
88
+ }
89
+ hash = terms.subject_hash;
90
+ inReplyTo = terms.id;
91
+ // Agreement is two approvals of the same terms (§5). Only approvals made
92
+ // after those terms were tabled count — an approval of an earlier,
93
+ // identical offer was answered by a counter in between.
94
+ bothApproved = history.some((m) => m.seq > terms.seq &&
95
+ m.verdict === "approve" &&
96
+ m.subject_hash === hash &&
97
+ m.from_id !== req.actor);
98
+ break;
99
+ }
100
+ case "reject":
101
+ case "cancel":
102
+ break;
103
+ }
104
+ return {
105
+ message: {
106
+ verdict: req.verdict,
107
+ in_reply_to: inReplyTo,
108
+ subject_hash: hash,
109
+ subject,
110
+ deadline,
111
+ },
112
+ next: nextState(room, req.verdict, {
113
+ bothApproved,
114
+ participants: parties.map((p) => p.id),
115
+ deadline,
116
+ }),
117
+ };
118
+ }
@@ -0,0 +1,35 @@
1
+ import { type Verdict } from "./envelope.js";
2
+ export type RoomState = "draft" | "awaiting" | "agreed" | "closed" | "expired";
3
+ export declare class MoveError extends Error {
4
+ }
5
+ export interface RoomView {
6
+ id: string;
7
+ state: RoomState;
8
+ round: number;
9
+ turn: string | null;
10
+ deadline: string | null;
11
+ }
12
+ export declare function isTerminal(state: RoomState): boolean;
13
+ /** A live room whose deadline has passed is expired, whether or not it is stored as such yet (§6). */
14
+ export declare function isOverdue(room: RoomView, now: Date): boolean;
15
+ /** The room as it stands at `now`: an overdue room reads as expired (§6). */
16
+ export declare function asOf<R extends RoomView>(room: R, now: Date): R;
17
+ /**
18
+ * Whether `actor` may play `verdict` right now. Rejects rather than repairs —
19
+ * the caller surfaces the reason to the agent, which surfaces it to its human.
20
+ */
21
+ export declare function assertMoveAllowed(room: RoomView, actor: string, verdict: Verdict, now: Date): void;
22
+ /**
23
+ * The room after a move. `approve` alone is not agreement: both sides must
24
+ * approve the same terms, which the caller checks by subject_hash (§3).
25
+ *
26
+ * `participants` holds both parties' ids. RoomView does not carry them, so the
27
+ * caller supplies them — without that the turn cannot be passed on.
28
+ *
29
+ * `deadline` is the mover's; a live room takes it over, replacing the last one.
30
+ */
31
+ export declare function nextState(room: RoomView, verdict: Verdict, opts: {
32
+ bothApproved: boolean;
33
+ participants: readonly string[];
34
+ deadline: string | null;
35
+ }): Omit<RoomView, "id">;
package/dist/state.js ADDED
@@ -0,0 +1,76 @@
1
+ // SPEC.md §5. Terminal states are terminal; a new negotiation is a new room.
2
+ import { CONTINUING } from "./envelope.js";
3
+ export class MoveError extends Error {
4
+ }
5
+ export function isTerminal(state) {
6
+ return state === "agreed" || state === "closed" || state === "expired";
7
+ }
8
+ /** A live room whose deadline has passed is expired, whether or not it is stored as such yet (§6). */
9
+ export function isOverdue(room, now) {
10
+ return (!isTerminal(room.state) && room.deadline !== null && Date.parse(room.deadline) <= now.getTime());
11
+ }
12
+ /** The room as it stands at `now`: an overdue room reads as expired (§6). */
13
+ export function asOf(room, now) {
14
+ return isOverdue(room, now) ? { ...room, state: "expired", turn: null } : room;
15
+ }
16
+ /**
17
+ * Whether `actor` may play `verdict` right now. Rejects rather than repairs —
18
+ * the caller surfaces the reason to the agent, which surfaces it to its human.
19
+ */
20
+ export function assertMoveAllowed(room, actor, verdict, now) {
21
+ if (isTerminal(room.state)) {
22
+ throw new MoveError(`room is ${room.state} and accepts no further moves`);
23
+ }
24
+ if (room.state === "draft") {
25
+ throw new MoveError("peer has not joined yet");
26
+ }
27
+ if (room.turn !== actor && verdict !== "cancel") {
28
+ throw new MoveError("not your turn");
29
+ }
30
+ if (isOverdue(room, now)) {
31
+ throw new MoveError("deadline has passed; the room is expired");
32
+ }
33
+ }
34
+ /**
35
+ * The room after a move. `approve` alone is not agreement: both sides must
36
+ * approve the same terms, which the caller checks by subject_hash (§3).
37
+ *
38
+ * `participants` holds both parties' ids. RoomView does not carry them, so the
39
+ * caller supplies them — without that the turn cannot be passed on.
40
+ *
41
+ * `deadline` is the mover's; a live room takes it over, replacing the last one.
42
+ */
43
+ export function nextState(room, verdict, opts) {
44
+ if (verdict === "reject" || verdict === "cancel") {
45
+ return { state: "closed", round: room.round, turn: null, deadline: room.deadline };
46
+ }
47
+ const { deadline } = opts;
48
+ if (verdict === "approve") {
49
+ return opts.bothApproved
50
+ ? { state: "agreed", round: room.round, turn: null, deadline }
51
+ : {
52
+ state: "awaiting",
53
+ round: room.round,
54
+ turn: otherThan(opts.participants, room.turn),
55
+ deadline,
56
+ };
57
+ }
58
+ if (CONTINUING.has(verdict)) {
59
+ return {
60
+ state: "awaiting",
61
+ round: room.round + 1,
62
+ turn: otherThan(opts.participants, room.turn),
63
+ deadline,
64
+ };
65
+ }
66
+ throw new MoveError(`unhandled verdict ${verdict}`);
67
+ }
68
+ // Two parties per room in v1 (SPEC.md §1 non-goals), so "the other one"
69
+ // is well defined. A live room with a null turn is one nobody can move in, so
70
+ // refuse the move rather than write that state.
71
+ function otherThan(participants, current) {
72
+ if (participants.length !== 2 || current === null || !participants.includes(current)) {
73
+ throw new MoveError("room does not have two parties with a defined turn");
74
+ }
75
+ return participants.find((p) => p !== current);
76
+ }
@@ -0,0 +1,23 @@
1
+ export type FieldType = "string" | "int" | "bool" | "timerange" | "enum" | "money" | "date";
2
+ export interface FieldDecl {
3
+ type: FieldType;
4
+ /** Shown to a human. Written by the counterparty — render as untrusted (§4.3). */
5
+ label: string;
6
+ optional?: boolean;
7
+ /** `enum` only. */
8
+ values?: string[];
9
+ /** `money` only: fixes the currency (ISO 4217) when the field has one. */
10
+ currency?: string;
11
+ /** `string` only; hard cap regardless. */
12
+ max_len?: number;
13
+ /** Wrap the declared type in an array. */
14
+ repeated?: boolean;
15
+ }
16
+ export type SubjectSchema = Record<string, FieldDecl>;
17
+ export declare class SubjectError extends Error {
18
+ }
19
+ export declare function parseSchema(raw: unknown): SubjectSchema;
20
+ /** Digits after the point for a currency: 2 for EUR, 0 for JPY, 3 for KWD. */
21
+ export declare function minorDigits(currency: string): number;
22
+ /** Validates a subject against a declared schema. Throws on the first problem. */
23
+ export declare function validateSubject(schema: SubjectSchema, subject: unknown): Record<string, unknown>;