@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.
- package/LICENSE +202 -0
- package/README.md +78 -2
- package/SPEC.md +386 -0
- package/dist/envelope.d.ts +32 -0
- package/dist/envelope.js +48 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +8 -0
- package/dist/limits.d.ts +38 -0
- package/dist/limits.js +214 -0
- package/dist/move.d.ts +47 -0
- package/dist/move.js +118 -0
- package/dist/state.d.ts +35 -0
- package/dist/state.js +76 -0
- package/dist/subject.d.ts +23 -0
- package/dist/subject.js +186 -0
- package/package.json +51 -4
- package/schema/envelope.schema.json +57 -0
- package/schema/limits.schema.json +32 -0
- package/schema/subject-schema.schema.json +47 -0
- package/vectors/canonical.json +59 -0
- package/vectors/circling.json +63 -0
- package/vectors/limits.json +138 -0
- package/vectors/moves.json +130 -0
- package/vectors/subjects.json +152 -0
package/dist/subject.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The free half (SPEC.md §4). The protocol does not know what is being
|
|
2
|
+
// negotiated; each party declares the shape of its own subject when it enters
|
|
3
|
+
// the room, and that declaration is what the peer validates against and what
|
|
4
|
+
// the confirmation card renders from.
|
|
5
|
+
//
|
|
6
|
+
// Validation is strict: an unknown field rejects the whole message rather than
|
|
7
|
+
// being ignored, because an ignored field is a place for text to hide.
|
|
8
|
+
const MAX_FIELDS = 24;
|
|
9
|
+
const MAX_STRING = 500;
|
|
10
|
+
const MAX_ITEMS = 32;
|
|
11
|
+
export class SubjectError extends Error {
|
|
12
|
+
}
|
|
13
|
+
function fail(path, why) {
|
|
14
|
+
throw new SubjectError(`${path}: ${why}`);
|
|
15
|
+
}
|
|
16
|
+
export function parseSchema(raw) {
|
|
17
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
18
|
+
fail("subject_schema", "must be an object of field declarations");
|
|
19
|
+
}
|
|
20
|
+
const entries = Object.entries(raw);
|
|
21
|
+
if (entries.length === 0)
|
|
22
|
+
fail("subject_schema", "declares no fields");
|
|
23
|
+
if (entries.length > MAX_FIELDS) {
|
|
24
|
+
fail("subject_schema", `declares more than ${MAX_FIELDS} fields`);
|
|
25
|
+
}
|
|
26
|
+
const schema = {};
|
|
27
|
+
for (const [name, decl] of entries) {
|
|
28
|
+
const at = `subject_schema.${name}`;
|
|
29
|
+
if (!/^[a-z][a-z0-9_]{0,31}$/.test(name))
|
|
30
|
+
fail(at, "invalid field name: use a-z, 0-9 and _, starting with a letter, at most 32 characters " +
|
|
31
|
+
'(e.g. "meeting_time"). Put the human wording, in any language, in `label`');
|
|
32
|
+
if (decl === null || typeof decl !== "object")
|
|
33
|
+
fail(at, "must be an object");
|
|
34
|
+
const d = decl;
|
|
35
|
+
const type = d.type;
|
|
36
|
+
if (type !== "string" &&
|
|
37
|
+
type !== "int" &&
|
|
38
|
+
type !== "bool" &&
|
|
39
|
+
type !== "timerange" &&
|
|
40
|
+
type !== "enum" &&
|
|
41
|
+
type !== "money" &&
|
|
42
|
+
type !== "date") {
|
|
43
|
+
fail(at, "unknown type");
|
|
44
|
+
}
|
|
45
|
+
if (typeof d.label !== "string" || d.label.length === 0 || d.label.length > 80) {
|
|
46
|
+
fail(at, "label must be a string of 1..80 chars");
|
|
47
|
+
}
|
|
48
|
+
if (type === "enum") {
|
|
49
|
+
if (!Array.isArray(d.values) || d.values.length < 2 || d.values.length > 32) {
|
|
50
|
+
fail(at, "enum needs 2..32 values");
|
|
51
|
+
}
|
|
52
|
+
if (!d.values.every((v) => typeof v === "string" && v.length <= 40)) {
|
|
53
|
+
fail(at, "enum values must be short strings");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (d.currency !== undefined && (type !== "money" || !isCurrency(d.currency))) {
|
|
57
|
+
fail(at, "currency is a three-letter ISO 4217 code, for money fields only");
|
|
58
|
+
}
|
|
59
|
+
schema[name] = {
|
|
60
|
+
type,
|
|
61
|
+
label: d.label,
|
|
62
|
+
optional: d.optional === true,
|
|
63
|
+
repeated: d.repeated === true,
|
|
64
|
+
values: type === "enum" ? d.values : undefined,
|
|
65
|
+
currency: type === "money" ? d.currency : undefined,
|
|
66
|
+
max_len: type === "string"
|
|
67
|
+
? Math.min(typeof d.max_len === "number" ? d.max_len : MAX_STRING, MAX_STRING)
|
|
68
|
+
: undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return schema;
|
|
72
|
+
}
|
|
73
|
+
const isCurrency = (code) => typeof code === "string" && /^[A-Z]{3}$/.test(code);
|
|
74
|
+
/** Digits after the point for a currency: 2 for EUR, 0 for JPY, 3 for KWD. */
|
|
75
|
+
export function minorDigits(currency) {
|
|
76
|
+
return new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions()
|
|
77
|
+
.maximumFractionDigits;
|
|
78
|
+
}
|
|
79
|
+
/** YYYY-MM-DD that names a real day: no 2026-02-30. */
|
|
80
|
+
function isDate(value) {
|
|
81
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
82
|
+
return false;
|
|
83
|
+
const d = new Date(`${value}T00:00:00Z`);
|
|
84
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().startsWith(value);
|
|
85
|
+
}
|
|
86
|
+
function checkScalar(decl, value, at) {
|
|
87
|
+
switch (decl.type) {
|
|
88
|
+
case "string":
|
|
89
|
+
if (typeof value !== "string")
|
|
90
|
+
fail(at, "expected string");
|
|
91
|
+
if (value.length > (decl.max_len ?? MAX_STRING))
|
|
92
|
+
fail(at, "string too long");
|
|
93
|
+
return;
|
|
94
|
+
case "int":
|
|
95
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
96
|
+
fail(at, "expected integer");
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
case "bool":
|
|
100
|
+
if (typeof value !== "boolean")
|
|
101
|
+
fail(at, "expected boolean");
|
|
102
|
+
return;
|
|
103
|
+
case "enum":
|
|
104
|
+
if (typeof value !== "string" || !decl.values.includes(value)) {
|
|
105
|
+
fail(at, "value outside declared set");
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
case "date":
|
|
109
|
+
if (!isDate(value))
|
|
110
|
+
fail(at, "expected a date, YYYY-MM-DD");
|
|
111
|
+
return;
|
|
112
|
+
case "money": {
|
|
113
|
+
// Amount as a decimal string, so no float ever stands between the
|
|
114
|
+
// number an agent meant and the one a human confirms.
|
|
115
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
116
|
+
fail(at, "expected {amount, currency}");
|
|
117
|
+
}
|
|
118
|
+
const keys = Object.keys(value);
|
|
119
|
+
if (keys.length !== 2 || !keys.includes("amount") || !keys.includes("currency")) {
|
|
120
|
+
fail(at, "money takes exactly amount and currency");
|
|
121
|
+
}
|
|
122
|
+
const { amount, currency } = value;
|
|
123
|
+
if (!isCurrency(currency))
|
|
124
|
+
fail(at, "currency must be a three-letter ISO 4217 code");
|
|
125
|
+
if (decl.currency && currency !== decl.currency)
|
|
126
|
+
fail(at, `currency must be ${decl.currency}`);
|
|
127
|
+
const digits = minorDigits(currency);
|
|
128
|
+
const shape = digits ? `^\\d{1,15}(\\.\\d{1,${digits}})?$` : "^\\d{1,15}$";
|
|
129
|
+
if (typeof amount !== "string" || !new RegExp(shape).test(amount)) {
|
|
130
|
+
fail(at, `amount must be a decimal string with at most ${digits} digits after the point`);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case "timerange": {
|
|
135
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
136
|
+
fail(at, "expected {start, end}");
|
|
137
|
+
}
|
|
138
|
+
const keys = Object.keys(value);
|
|
139
|
+
if (keys.length !== 2 || !keys.includes("start") || !keys.includes("end")) {
|
|
140
|
+
fail(at, "timerange takes exactly start and end");
|
|
141
|
+
}
|
|
142
|
+
const { start, end } = value;
|
|
143
|
+
const s = typeof start === "string" ? Date.parse(start) : NaN;
|
|
144
|
+
const e = typeof end === "string" ? Date.parse(end) : NaN;
|
|
145
|
+
if (Number.isNaN(s) || Number.isNaN(e))
|
|
146
|
+
fail(at, "start/end must be RFC3339");
|
|
147
|
+
if (e <= s)
|
|
148
|
+
fail(at, "end must follow start");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Validates a subject against a declared schema. Throws on the first problem. */
|
|
154
|
+
export function validateSubject(schema, subject) {
|
|
155
|
+
if (subject === null || typeof subject !== "object" || Array.isArray(subject)) {
|
|
156
|
+
fail("subject", "must be an object");
|
|
157
|
+
}
|
|
158
|
+
const value = subject;
|
|
159
|
+
for (const key of Object.keys(value)) {
|
|
160
|
+
if (!(key in schema))
|
|
161
|
+
fail(`subject.${key}`, "not in the declared schema");
|
|
162
|
+
}
|
|
163
|
+
for (const [name, decl] of Object.entries(schema)) {
|
|
164
|
+
const at = `subject.${name}`;
|
|
165
|
+
const v = value[name];
|
|
166
|
+
if (v === undefined || v === null) {
|
|
167
|
+
if (!decl.optional)
|
|
168
|
+
fail(at, "required field missing");
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (decl.repeated) {
|
|
172
|
+
if (!Array.isArray(v))
|
|
173
|
+
fail(at, "expected an array");
|
|
174
|
+
if (v.length === 0)
|
|
175
|
+
fail(at, "array is empty");
|
|
176
|
+
if (v.length > MAX_ITEMS)
|
|
177
|
+
fail(at, `more than ${MAX_ITEMS} items`);
|
|
178
|
+
for (const [i, item] of v.entries())
|
|
179
|
+
checkScalar(decl, item, `${at}[${i}]`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
checkScalar(decl, v, at);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taasky/protocol",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Taasky Negotiation Protocol: a spec and reference implementation for two agents negotiating terms their humans confirm.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Taasky",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/taasky/protocol.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/taasky/protocol#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/taasky/protocol/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"agents",
|
|
18
|
+
"negotiation",
|
|
19
|
+
"mcp",
|
|
20
|
+
"agent-to-agent",
|
|
21
|
+
"protocol"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"schema",
|
|
32
|
+
"vectors",
|
|
33
|
+
"SPEC.md"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"lint": "biome check .",
|
|
42
|
+
"fix": "biome check --write .",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"check": "biome check . && tsc --noEmit && vitest run",
|
|
45
|
+
"prepublishOnly": "npm run check && npm run build"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "2.5.15",
|
|
49
|
+
"@types/node": "24.0.0",
|
|
50
|
+
"typescript": "7.0.2",
|
|
51
|
+
"vitest": "5.0.3"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/taasky/protocol/schema/envelope.schema.json",
|
|
4
|
+
"title": "Envelope",
|
|
5
|
+
"description": "One move in a room (SPEC.md §3). The relay reads this half; `subject` is validated against the sender's declared subject schema, not here.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"v",
|
|
10
|
+
"room",
|
|
11
|
+
"msg",
|
|
12
|
+
"from",
|
|
13
|
+
"round",
|
|
14
|
+
"verdict",
|
|
15
|
+
"in_reply_to",
|
|
16
|
+
"subject_hash",
|
|
17
|
+
"deadline",
|
|
18
|
+
"ts",
|
|
19
|
+
"subject"
|
|
20
|
+
],
|
|
21
|
+
"properties": {
|
|
22
|
+
"v": { "const": 1, "description": "Protocol major version." },
|
|
23
|
+
"room": { "type": "string", "minLength": 1 },
|
|
24
|
+
"msg": { "type": "string", "minLength": 1, "description": "Unique per room." },
|
|
25
|
+
"from": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"minLength": 1,
|
|
28
|
+
"description": "Assigned by the relay, never echoed from the sender."
|
|
29
|
+
},
|
|
30
|
+
"round": { "type": "integer", "minimum": 0 },
|
|
31
|
+
"verdict": { "enum": ["offer", "counter", "need_more_detail", "approve", "reject", "cancel"] },
|
|
32
|
+
"in_reply_to": { "type": ["string", "null"] },
|
|
33
|
+
"subject_hash": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" },
|
|
34
|
+
"deadline": { "type": ["string", "null"], "format": "date-time" },
|
|
35
|
+
"ts": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"format": "date-time",
|
|
38
|
+
"description": "Set by the relay on receipt."
|
|
39
|
+
},
|
|
40
|
+
"subject": { "type": ["object", "null"] }
|
|
41
|
+
},
|
|
42
|
+
"allOf": [
|
|
43
|
+
{
|
|
44
|
+
"if": { "properties": { "verdict": { "enum": ["offer", "counter"] } } },
|
|
45
|
+
"then": {
|
|
46
|
+
"properties": { "subject": { "type": "object" }, "subject_hash": { "type": "string" } }
|
|
47
|
+
},
|
|
48
|
+
"else": { "properties": { "subject": { "type": "null" } } }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"if": { "properties": { "verdict": { "const": "approve" } } },
|
|
52
|
+
"then": {
|
|
53
|
+
"properties": { "subject_hash": { "type": "string" }, "in_reply_to": { "type": "string" } }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/taasky/protocol/schema/limits.schema.json",
|
|
4
|
+
"title": "Private limits",
|
|
5
|
+
"description": "A party's bounds per field of its own subject schema (SPEC.md §7). Seen by the relay only. Which shape a field takes, and what its bounds look like, follows from the field's declared type: min/max for int, money, date and timerange; one_of for enum; equals for bool; nothing for string.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,31}$" },
|
|
8
|
+
"additionalProperties": {
|
|
9
|
+
"oneOf": [
|
|
10
|
+
{
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"minProperties": 1,
|
|
14
|
+
"properties": { "min": {}, "max": {} }
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"required": ["one_of"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"one_of": { "type": "array", "minItems": 1, "items": { "type": "string" } }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"required": ["equals"],
|
|
28
|
+
"properties": { "equals": { "type": "boolean" } }
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/taasky/protocol/schema/subject-schema.schema.json",
|
|
4
|
+
"title": "Subject schema",
|
|
5
|
+
"description": "The shape of a party's terms, declared when it opens or joins a room (SPEC.md §4). Labels are written by the counterparty and are untrusted text.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"minProperties": 1,
|
|
8
|
+
"maxProperties": 24,
|
|
9
|
+
"propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,31}$" },
|
|
10
|
+
"additionalProperties": { "$ref": "#/$defs/field" },
|
|
11
|
+
"$defs": {
|
|
12
|
+
"field": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"required": ["type", "label"],
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"type": { "enum": ["string", "int", "bool", "enum", "timerange", "date", "money"] },
|
|
18
|
+
"label": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
19
|
+
"optional": { "type": "boolean" },
|
|
20
|
+
"repeated": { "type": "boolean", "description": "An array of 1 to 32 values of the type." },
|
|
21
|
+
"values": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"minItems": 2,
|
|
24
|
+
"maxItems": 32,
|
|
25
|
+
"items": { "type": "string", "maxLength": 40 }
|
|
26
|
+
},
|
|
27
|
+
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
|
|
28
|
+
"max_len": {
|
|
29
|
+
"type": "integer",
|
|
30
|
+
"minimum": 1,
|
|
31
|
+
"description": "Capped at 500 whatever is declared."
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"allOf": [
|
|
35
|
+
{
|
|
36
|
+
"if": { "properties": { "type": { "const": "enum" } } },
|
|
37
|
+
"then": { "required": ["values"] },
|
|
38
|
+
"else": { "not": { "required": ["values"] } }
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"if": { "not": { "properties": { "type": { "const": "money" } } } },
|
|
42
|
+
"then": { "not": { "required": ["currency"] } }
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "canonicalize(subject) sorts keys at every level, by UTF-16 code unit, and drops nothing but undefined; subject_hash is \"sha256:\" + hex SHA-256 of its UTF-8 bytes (SPEC.md §3).",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"name": "meeting terms",
|
|
6
|
+
"input": {
|
|
7
|
+
"slot": {
|
|
8
|
+
"start": "2026-10-14T10:00:00Z",
|
|
9
|
+
"end": "2026-10-14T10:30:00Z"
|
|
10
|
+
},
|
|
11
|
+
"duration_min": 30
|
|
12
|
+
},
|
|
13
|
+
"canonical": "{\"duration_min\":30,\"slot\":{\"end\":\"2026-10-14T10:30:00Z\",\"start\":\"2026-10-14T10:00:00Z\"}}",
|
|
14
|
+
"subject_hash": "sha256:a6819e25e6eec105881568b2a3d4e06f03bfac1b7b2c147bab87e6861af716c8"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "same terms, keys in another order",
|
|
18
|
+
"input": {
|
|
19
|
+
"duration_min": 30,
|
|
20
|
+
"slot": {
|
|
21
|
+
"end": "2026-10-14T10:30:00Z",
|
|
22
|
+
"start": "2026-10-14T10:00:00Z"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"canonical": "{\"duration_min\":30,\"slot\":{\"end\":\"2026-10-14T10:30:00Z\",\"start\":\"2026-10-14T10:00:00Z\"}}",
|
|
26
|
+
"subject_hash": "sha256:a6819e25e6eec105881568b2a3d4e06f03bfac1b7b2c147bab87e6861af716c8"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "money, arrays and booleans",
|
|
30
|
+
"input": {
|
|
31
|
+
"price": {
|
|
32
|
+
"currency": "EUR",
|
|
33
|
+
"amount": "1500.00"
|
|
34
|
+
},
|
|
35
|
+
"days": ["2026-11-02", "2026-11-03"],
|
|
36
|
+
"delivery": true
|
|
37
|
+
},
|
|
38
|
+
"canonical": "{\"days\":[\"2026-11-02\",\"2026-11-03\"],\"delivery\":true,\"price\":{\"amount\":\"1500.00\",\"currency\":\"EUR\"}}",
|
|
39
|
+
"subject_hash": "sha256:6b07e465b36a22ad60fa74d9cf311d5be4d7833a2db4ec7cb0624719f64c4910"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "non-ASCII labels survive as UTF-8",
|
|
43
|
+
"input": {
|
|
44
|
+
"note": "Встреча в кафе",
|
|
45
|
+
"place": "café"
|
|
46
|
+
},
|
|
47
|
+
"canonical": "{\"note\":\"Встреча в кафе\",\"place\":\"café\"}",
|
|
48
|
+
"subject_hash": "sha256:485be4bc83fdadfd5b5bfaf71afed7818ff432b20acaaa9754bc35274dd16e66"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "arrays keep their order",
|
|
52
|
+
"input": {
|
|
53
|
+
"days": ["2026-11-03", "2026-11-02"]
|
|
54
|
+
},
|
|
55
|
+
"canonical": "{\"days\":[\"2026-11-03\",\"2026-11-02\"]}",
|
|
56
|
+
"subject_hash": "sha256:f96c5ae801e9b3091f11a086f175b5beb1b35186d5b01b3355b66453f47b3c0a"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "isCircling(history) (SPEC.md §7): the last 4 moves are all counter or need_more_detail, and none tabled terms not seen before them. Each move is [verdict, subject_hash or null].",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"name": "two positions, restated twice",
|
|
6
|
+
"moves": [
|
|
7
|
+
["offer", "h1"],
|
|
8
|
+
["counter", "h2"],
|
|
9
|
+
["counter", "h1"],
|
|
10
|
+
["counter", "h2"],
|
|
11
|
+
["counter", "h1"],
|
|
12
|
+
["counter", "h2"]
|
|
13
|
+
],
|
|
14
|
+
"circling": true
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "one move short",
|
|
18
|
+
"moves": [
|
|
19
|
+
["offer", "h1"],
|
|
20
|
+
["counter", "h2"],
|
|
21
|
+
["counter", "h1"],
|
|
22
|
+
["counter", "h2"],
|
|
23
|
+
["counter", "h1"]
|
|
24
|
+
],
|
|
25
|
+
"circling": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "asking each other the same thing",
|
|
29
|
+
"moves": [
|
|
30
|
+
["offer", "h1"],
|
|
31
|
+
["need_more_detail", null],
|
|
32
|
+
["need_more_detail", null],
|
|
33
|
+
["need_more_detail", null],
|
|
34
|
+
["need_more_detail", null]
|
|
35
|
+
],
|
|
36
|
+
"circling": true
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "a small concession is progress",
|
|
40
|
+
"moves": [
|
|
41
|
+
["offer", "h1"],
|
|
42
|
+
["counter", "h2"],
|
|
43
|
+
["counter", "h1"],
|
|
44
|
+
["counter", "h2"],
|
|
45
|
+
["counter", "h1"],
|
|
46
|
+
["counter", "h3"]
|
|
47
|
+
],
|
|
48
|
+
"circling": false
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "an approval in the window",
|
|
52
|
+
"moves": [
|
|
53
|
+
["offer", "h1"],
|
|
54
|
+
["counter", "h2"],
|
|
55
|
+
["counter", "h1"],
|
|
56
|
+
["approve", "h1"],
|
|
57
|
+
["counter", "h2"],
|
|
58
|
+
["counter", "h1"]
|
|
59
|
+
],
|
|
60
|
+
"circling": false
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Private limits (SPEC.md §7). check: where terms break one party's limits. cannot_meet: the fields on which two parties' limits leave no deal.",
|
|
3
|
+
"schema": {
|
|
4
|
+
"price": { "type": "money", "label": "Price", "currency": "EUR" },
|
|
5
|
+
"meeting_time": { "type": "timerange", "label": "Meeting time" },
|
|
6
|
+
"seats": { "type": "int", "label": "Seats" },
|
|
7
|
+
"pickup": { "type": "date", "label": "Pickup day" },
|
|
8
|
+
"place": { "type": "enum", "label": "Place", "values": ["office", "cafe", "online"] },
|
|
9
|
+
"delivery": { "type": "bool", "label": "Delivery" },
|
|
10
|
+
"note": { "type": "string", "label": "Note", "optional": true }
|
|
11
|
+
},
|
|
12
|
+
"invalid": [
|
|
13
|
+
{ "name": "a field not in the schema", "limits": { "colour": { "max": 1 } } },
|
|
14
|
+
{ "name": "a string field", "limits": { "note": { "max": 1 } } },
|
|
15
|
+
{ "name": "min on an enum", "limits": { "place": { "max": "cafe" } } },
|
|
16
|
+
{ "name": "an enum value that is not declared", "limits": { "place": { "one_of": ["moon"] } } },
|
|
17
|
+
{
|
|
18
|
+
"name": "another currency than the field's",
|
|
19
|
+
"limits": { "price": { "max": { "amount": "100", "currency": "USD" } } }
|
|
20
|
+
},
|
|
21
|
+
{ "name": "min above max", "limits": { "seats": { "min": 5, "max": 4 } } },
|
|
22
|
+
{ "name": "a bare number for money", "limits": { "price": { "max": 100 } } },
|
|
23
|
+
{ "name": "a day that does not exist", "limits": { "pickup": { "min": "2026-02-30" } } }
|
|
24
|
+
],
|
|
25
|
+
"check": [
|
|
26
|
+
{
|
|
27
|
+
"name": "inside, touching the bounds",
|
|
28
|
+
"limits": {
|
|
29
|
+
"price": { "max": { "amount": "100.00", "currency": "EUR" } },
|
|
30
|
+
"meeting_time": { "min": "2026-12-01T09:00:00+01:00", "max": "2026-12-01T18:00:00+01:00" },
|
|
31
|
+
"place": { "one_of": ["cafe", "online"] }
|
|
32
|
+
},
|
|
33
|
+
"subject": {
|
|
34
|
+
"price": { "amount": "100", "currency": "EUR" },
|
|
35
|
+
"meeting_time": {
|
|
36
|
+
"start": "2026-12-01T09:00:00+01:00",
|
|
37
|
+
"end": "2026-12-01T10:00:00+01:00"
|
|
38
|
+
},
|
|
39
|
+
"place": "cafe"
|
|
40
|
+
},
|
|
41
|
+
"breaches": []
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "every breach, with the bound it broke",
|
|
45
|
+
"limits": {
|
|
46
|
+
"price": { "max": { "amount": "100.00", "currency": "EUR" } },
|
|
47
|
+
"meeting_time": { "min": "2026-12-01T09:00:00+01:00", "max": "2026-12-01T18:00:00+01:00" },
|
|
48
|
+
"place": { "one_of": ["cafe", "online"] }
|
|
49
|
+
},
|
|
50
|
+
"subject": {
|
|
51
|
+
"price": { "amount": "100.01", "currency": "EUR" },
|
|
52
|
+
"meeting_time": {
|
|
53
|
+
"start": "2026-12-01T17:30:00+01:00",
|
|
54
|
+
"end": "2026-12-01T18:30:00+01:00"
|
|
55
|
+
},
|
|
56
|
+
"place": "office"
|
|
57
|
+
},
|
|
58
|
+
"breaches": [
|
|
59
|
+
{
|
|
60
|
+
"field": "price",
|
|
61
|
+
"rule": "above_max",
|
|
62
|
+
"bound": { "amount": "100.00", "currency": "EUR" }
|
|
63
|
+
},
|
|
64
|
+
{ "field": "meeting_time", "rule": "above_max", "bound": "2026-12-01T18:00:00+01:00" },
|
|
65
|
+
{ "field": "place", "rule": "not_allowed", "bound": ["cafe", "online"] }
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "times compare across offsets",
|
|
70
|
+
"limits": { "meeting_time": { "min": "2026-12-01T09:00:00+01:00" } },
|
|
71
|
+
"subject": {
|
|
72
|
+
"meeting_time": { "start": "2026-12-01T08:30:00Z", "end": "2026-12-01T09:00:00Z" }
|
|
73
|
+
},
|
|
74
|
+
"breaches": []
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "amounts past 2^53 compare exactly",
|
|
78
|
+
"limits": { "price": { "max": { "amount": "999999999999999.99", "currency": "EUR" } } },
|
|
79
|
+
"subject": { "price": { "amount": "999999999999999.98", "currency": "EUR" } },
|
|
80
|
+
"breaches": []
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "below min, and a bool that is not the one asked for",
|
|
84
|
+
"limits": { "seats": { "min": 2 }, "delivery": { "equals": true } },
|
|
85
|
+
"subject": { "seats": 1, "delivery": false },
|
|
86
|
+
"breaches": [
|
|
87
|
+
{ "field": "seats", "rule": "below_min", "bound": 2 },
|
|
88
|
+
{ "field": "delivery", "rule": "not_equal", "bound": true }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"cannot_meet": [
|
|
93
|
+
{
|
|
94
|
+
"name": "a buyer's max below a seller's min",
|
|
95
|
+
"a": { "price": { "max": { "amount": "80.00", "currency": "EUR" } } },
|
|
96
|
+
"b": { "price": { "min": { "amount": "90.00", "currency": "EUR" } } },
|
|
97
|
+
"fields": ["price"]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "a max equal to a min is a deal",
|
|
101
|
+
"a": { "price": { "max": { "amount": "80.00", "currency": "EUR" } } },
|
|
102
|
+
"b": { "price": { "min": { "amount": "80", "currency": "EUR" } } },
|
|
103
|
+
"fields": []
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "a meeting needs time between start and end",
|
|
107
|
+
"a": { "meeting_time": { "max": "2026-12-01T12:00:00Z" } },
|
|
108
|
+
"b": { "meeting_time": { "min": "2026-12-01T12:00:00Z" } },
|
|
109
|
+
"fields": ["meeting_time"]
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "enums, bools and dates",
|
|
113
|
+
"a": {
|
|
114
|
+
"place": { "one_of": ["office"] },
|
|
115
|
+
"delivery": { "equals": true },
|
|
116
|
+
"pickup": { "max": "2026-12-01" }
|
|
117
|
+
},
|
|
118
|
+
"b": {
|
|
119
|
+
"place": { "one_of": ["cafe"] },
|
|
120
|
+
"delivery": { "equals": false },
|
|
121
|
+
"pickup": { "min": "2026-12-02" }
|
|
122
|
+
},
|
|
123
|
+
"fields": ["place", "delivery", "pickup"]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "one side set no limits",
|
|
127
|
+
"a": { "price": { "max": { "amount": "1.00", "currency": "EUR" } } },
|
|
128
|
+
"b": null,
|
|
129
|
+
"fields": []
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "two maxes always meet",
|
|
133
|
+
"a": { "seats": { "max": 2 } },
|
|
134
|
+
"b": { "seats": { "max": 1 } },
|
|
135
|
+
"fields": []
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|