eyeling 1.21.2 → 1.21.4
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/HANDBOOK.md +65 -0
- package/README.md +0 -1
- package/dist/browser/eyeling.browser.js +12 -1
- package/examples/arc-bridge/README.md +208 -0
- package/examples/arc-bridge/delfour/delfour.data.json +68 -0
- package/examples/arc-bridge/delfour/delfour.expected.json +88 -0
- package/examples/arc-bridge/delfour/delfour.instance.schema.json +201 -0
- package/examples/arc-bridge/delfour/delfour.model.mjs +273 -0
- package/examples/arc-bridge/delfour/delfour.spec.md +118 -0
- package/examples/arc-bridge/flandor/flandor.data.json +107 -0
- package/examples/arc-bridge/flandor/flandor.expected.json +98 -0
- package/examples/arc-bridge/flandor/flandor.instance.schema.json +285 -0
- package/examples/arc-bridge/flandor/flandor.model.mjs +303 -0
- package/examples/arc-bridge/flandor/flandor.spec.md +156 -0
- package/examples/extra/flandor.js +349 -0
- package/examples/extra/output/flandor.txt +31 -0
- package/examples/flandor.n3 +425 -0
- package/examples/output/flandor.n3 +31 -0
- package/eyeling.js +12 -1
- package/lib/builtins.js +12 -1
- package/package.json +1 -2
- package/test/api.test.js +49 -0
- package/SEMANTICS.md +0 -41
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.org/schema/delfour.instance.schema.json",
|
|
4
|
+
"title": "ARC Bridge Flandor instance",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"$schema",
|
|
9
|
+
"caseName",
|
|
10
|
+
"retailer",
|
|
11
|
+
"question",
|
|
12
|
+
"timestamps",
|
|
13
|
+
"evaluationContext",
|
|
14
|
+
"thresholds",
|
|
15
|
+
"householdProfile",
|
|
16
|
+
"catalog",
|
|
17
|
+
"scan",
|
|
18
|
+
"insightPolicy",
|
|
19
|
+
"integrity"
|
|
20
|
+
],
|
|
21
|
+
"properties": {
|
|
22
|
+
"$schema": {
|
|
23
|
+
"type": "string"
|
|
24
|
+
},
|
|
25
|
+
"caseName": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"minLength": 1
|
|
28
|
+
},
|
|
29
|
+
"retailer": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"minLength": 1
|
|
32
|
+
},
|
|
33
|
+
"question": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"minLength": 1
|
|
36
|
+
},
|
|
37
|
+
"timestamps": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"required": ["createdAt", "expiresAt", "authorizedAt", "dutyPerformedAt"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"createdAt": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"format": "date-time"
|
|
45
|
+
},
|
|
46
|
+
"expiresAt": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"format": "date-time"
|
|
49
|
+
},
|
|
50
|
+
"authorizedAt": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"format": "date-time"
|
|
53
|
+
},
|
|
54
|
+
"dutyPerformedAt": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"format": "date-time"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"evaluationContext": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"required": ["scopeDevice", "scopeEvent", "purpose", "prohibitedReusePurpose", "requestAction"],
|
|
64
|
+
"properties": {
|
|
65
|
+
"scopeDevice": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"minLength": 1
|
|
68
|
+
},
|
|
69
|
+
"scopeEvent": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"minLength": 1
|
|
72
|
+
},
|
|
73
|
+
"purpose": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"minLength": 1
|
|
76
|
+
},
|
|
77
|
+
"prohibitedReusePurpose": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"minLength": 1
|
|
80
|
+
},
|
|
81
|
+
"requestAction": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"minLength": 1
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"thresholds": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"required": ["sugarPerServingGAtLeast"],
|
|
91
|
+
"properties": {
|
|
92
|
+
"sugarPerServingGAtLeast": {
|
|
93
|
+
"type": "number"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"householdProfile": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"required": ["condition"],
|
|
101
|
+
"properties": {
|
|
102
|
+
"condition": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"minLength": 1
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"catalog": {
|
|
109
|
+
"type": "array",
|
|
110
|
+
"minItems": 1,
|
|
111
|
+
"items": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"additionalProperties": false,
|
|
114
|
+
"required": ["id", "name", "sugarTenths", "sugarPerServing"],
|
|
115
|
+
"properties": {
|
|
116
|
+
"id": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"minLength": 1
|
|
119
|
+
},
|
|
120
|
+
"name": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"minLength": 1
|
|
123
|
+
},
|
|
124
|
+
"sugarTenths": {
|
|
125
|
+
"type": "integer",
|
|
126
|
+
"minimum": 0
|
|
127
|
+
},
|
|
128
|
+
"sugarPerServing": {
|
|
129
|
+
"type": "number",
|
|
130
|
+
"minimum": 0
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"scan": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"additionalProperties": false,
|
|
138
|
+
"required": ["scannedProductId"],
|
|
139
|
+
"properties": {
|
|
140
|
+
"scannedProductId": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"minLength": 1
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
"insightPolicy": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"additionalProperties": false,
|
|
149
|
+
"required": ["id", "metric", "type", "suggestionPolicy", "policyType", "policyProfile"],
|
|
150
|
+
"properties": {
|
|
151
|
+
"id": {
|
|
152
|
+
"type": "string",
|
|
153
|
+
"minLength": 1
|
|
154
|
+
},
|
|
155
|
+
"metric": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"minLength": 1
|
|
158
|
+
},
|
|
159
|
+
"type": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"minLength": 1
|
|
162
|
+
},
|
|
163
|
+
"suggestionPolicy": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"minLength": 1
|
|
166
|
+
},
|
|
167
|
+
"policyType": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"minLength": 1
|
|
170
|
+
},
|
|
171
|
+
"policyProfile": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"minLength": 1
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"integrity": {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"additionalProperties": false,
|
|
180
|
+
"required": ["hashAlgorithm", "macAlgorithm", "secret", "verificationMode"],
|
|
181
|
+
"properties": {
|
|
182
|
+
"hashAlgorithm": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"const": "SHA-256"
|
|
185
|
+
},
|
|
186
|
+
"macAlgorithm": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"const": "HMAC-SHA-256"
|
|
189
|
+
},
|
|
190
|
+
"secret": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"minLength": 1
|
|
193
|
+
},
|
|
194
|
+
"verificationMode": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"minLength": 1
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { webcrypto } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const subtle = webcrypto.subtle;
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
|
|
12
|
+
function assert(condition, message) {
|
|
13
|
+
if (!condition) throw new Error(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function sha256Hex(text) {
|
|
17
|
+
const digest = await subtle.digest('SHA-256', encoder.encode(text));
|
|
18
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function hmacSha256Hex(secret, text) {
|
|
22
|
+
const key = await subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
23
|
+
const signature = await subtle.sign('HMAC', key, encoder.encode(text));
|
|
24
|
+
return Array.from(new Uint8Array(signature), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateInstance(data) {
|
|
28
|
+
assert(typeof data?.caseName === 'string' && data.caseName.length > 0, 'caseName is required');
|
|
29
|
+
assert(typeof data?.retailer === 'string' && data.retailer.length > 0, 'retailer is required');
|
|
30
|
+
assert(Array.isArray(data?.catalog) && data.catalog.length > 0, 'catalog is required');
|
|
31
|
+
assert(typeof data?.scan?.scannedProductId === 'string', 'scan.scannedProductId is required');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function productById(data, id) {
|
|
35
|
+
return data.catalog.find((product) => product.id === id) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clauseR1_needsLowSugar(data) {
|
|
39
|
+
return data.householdProfile.condition === 'Diabetes';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clauseR2_highSugarScanned(data, scannedProduct) {
|
|
43
|
+
return scannedProduct.sugarPerServing >= data.thresholds.sugarPerServingGAtLeast;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clauseR3_lowerSugarCandidates(data, scannedProduct) {
|
|
47
|
+
return data.catalog
|
|
48
|
+
.filter((product) => product.sugarTenths < scannedProduct.sugarTenths)
|
|
49
|
+
.sort((a, b) => a.sugarTenths - b.sugarTenths);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clauseR4_recommendedAlternative(data, scannedProduct) {
|
|
53
|
+
const candidates = clauseR3_lowerSugarCandidates(data, scannedProduct);
|
|
54
|
+
return {
|
|
55
|
+
candidates,
|
|
56
|
+
recommended: candidates[0] ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function clauseR5_alternativeLowersSugar(scannedProduct, recommended) {
|
|
61
|
+
return Boolean(recommended) && recommended.sugarTenths < scannedProduct.sugarTenths;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function deriveInsight(data) {
|
|
65
|
+
return {
|
|
66
|
+
createdAt: data.timestamps.createdAt,
|
|
67
|
+
expiresAt: data.timestamps.expiresAt,
|
|
68
|
+
id: data.insightPolicy.id,
|
|
69
|
+
metric: data.insightPolicy.metric,
|
|
70
|
+
retailer: data.retailer,
|
|
71
|
+
scopeDevice: data.evaluationContext.scopeDevice,
|
|
72
|
+
scopeEvent: data.evaluationContext.scopeEvent,
|
|
73
|
+
suggestionPolicy: data.insightPolicy.suggestionPolicy,
|
|
74
|
+
threshold: data.thresholds.sugarPerServingGAtLeast,
|
|
75
|
+
type: data.insightPolicy.type,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function derivePolicy(data) {
|
|
80
|
+
return {
|
|
81
|
+
duty: {
|
|
82
|
+
action: 'odrl:delete',
|
|
83
|
+
constraint: {
|
|
84
|
+
leftOperand: 'odrl:dateTime',
|
|
85
|
+
operator: 'odrl:eq',
|
|
86
|
+
rightOperand: data.timestamps.expiresAt,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
permission: {
|
|
90
|
+
action: data.evaluationContext.requestAction,
|
|
91
|
+
constraint: {
|
|
92
|
+
leftOperand: 'odrl:purpose',
|
|
93
|
+
operator: 'odrl:eq',
|
|
94
|
+
rightOperand: data.evaluationContext.purpose,
|
|
95
|
+
},
|
|
96
|
+
target: data.insightPolicy.id,
|
|
97
|
+
},
|
|
98
|
+
profile: data.insightPolicy.policyProfile,
|
|
99
|
+
prohibition: {
|
|
100
|
+
action: 'odrl:distribute',
|
|
101
|
+
constraint: {
|
|
102
|
+
leftOperand: 'odrl:purpose',
|
|
103
|
+
operator: 'odrl:eq',
|
|
104
|
+
rightOperand: data.evaluationContext.prohibitedReusePurpose,
|
|
105
|
+
},
|
|
106
|
+
target: data.insightPolicy.id,
|
|
107
|
+
},
|
|
108
|
+
type: data.insightPolicy.policyType,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function clauseG1_authorizedUse(data) {
|
|
113
|
+
return (
|
|
114
|
+
data.evaluationContext.requestAction === 'odrl:use' &&
|
|
115
|
+
data.evaluationContext.purpose === 'shopping_assist' &&
|
|
116
|
+
Date.parse(data.timestamps.authorizedAt) <= Date.parse(data.timestamps.expiresAt)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function clauseG2_marketingProhibited(policy) {
|
|
121
|
+
return (
|
|
122
|
+
policy.prohibition?.action === 'odrl:distribute' && policy.prohibition?.constraint?.rightOperand === 'marketing'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clauseG3_dutyTimely(data) {
|
|
127
|
+
return Date.parse(data.timestamps.dutyPerformedAt) <= Date.parse(data.timestamps.expiresAt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function clauseM1_canonicalEnvelope(data) {
|
|
131
|
+
const insight = deriveInsight(data);
|
|
132
|
+
const policy = derivePolicy(data);
|
|
133
|
+
const envelope = { insight, policy };
|
|
134
|
+
const canonicalEnvelope = `{"insight":{"createdAt":"${insight.createdAt}","expiresAt":"${insight.expiresAt}","id":"${insight.id}","metric":"${insight.metric}","retailer":"${insight.retailer}","scopeDevice":"${insight.scopeDevice}","scopeEvent":"${insight.scopeEvent}","suggestionPolicy":"${insight.suggestionPolicy}","threshold":10.0,"type":"${insight.type}"},"policy":{"duty":{"action":"${policy.duty.action}","constraint":{"leftOperand":"${policy.duty.constraint.leftOperand}","operator":"${policy.duty.constraint.operator}","rightOperand":"${policy.duty.constraint.rightOperand}"}},"permission":{"action":"${policy.permission.action}","constraint":{"leftOperand":"${policy.permission.constraint.leftOperand}","operator":"${policy.permission.constraint.operator}","rightOperand":"${policy.permission.constraint.rightOperand}"},"target":"${policy.permission.target}"},"profile":"${policy.profile}","prohibition":{"action":"${policy.prohibition.action}","constraint":{"leftOperand":"${policy.prohibition.constraint.leftOperand}","operator":"${policy.prohibition.constraint.operator}","rightOperand":"${policy.prohibition.constraint.rightOperand}"},"target":"${policy.prohibition.target}"},"type":"${policy.type}"}}`;
|
|
135
|
+
return { envelope, canonicalEnvelope };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function clauseM2_payloadHash(data) {
|
|
139
|
+
const { envelope, canonicalEnvelope } = clauseM1_canonicalEnvelope(data);
|
|
140
|
+
const payloadHashSHA256 = await sha256Hex(canonicalEnvelope);
|
|
141
|
+
return { envelope, canonicalEnvelope, payloadHashSHA256 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function clauseM3_hmac(data) {
|
|
145
|
+
const { envelope, canonicalEnvelope, payloadHashSHA256 } = await clauseM2_payloadHash(data);
|
|
146
|
+
const envelopeHmacSHA256 = await hmacSha256Hex(data.integrity.secret, canonicalEnvelope);
|
|
147
|
+
return { envelope, canonicalEnvelope, payloadHashSHA256, envelopeHmacSHA256 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function clauseM4_minimizationRespected(insight) {
|
|
151
|
+
return !JSON.stringify(insight)
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.match(/diabetes|medical/);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function clauseM5_scopeComplete(insight) {
|
|
157
|
+
return Boolean(insight.scopeDevice && insight.scopeEvent && insight.expiresAt);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function evaluate(data) {
|
|
161
|
+
validateInstance(data);
|
|
162
|
+
|
|
163
|
+
const scannedProduct = productById(data, data.scan.scannedProductId);
|
|
164
|
+
assert(scannedProduct, `scanned product not found: ${data.scan.scannedProductId}`);
|
|
165
|
+
|
|
166
|
+
const needsLowSugar = clauseR1_needsLowSugar(data);
|
|
167
|
+
const highSugarScanned = clauseR2_highSugarScanned(data, scannedProduct);
|
|
168
|
+
const { candidates, recommended } = clauseR4_recommendedAlternative(data, scannedProduct);
|
|
169
|
+
const alternativeLowersSugar = clauseR5_alternativeLowersSugar(scannedProduct, recommended);
|
|
170
|
+
|
|
171
|
+
const insight = deriveInsight(data);
|
|
172
|
+
const policy = derivePolicy(data);
|
|
173
|
+
const { canonicalEnvelope, payloadHashSHA256, envelopeHmacSHA256 } = await clauseM3_hmac(data);
|
|
174
|
+
const minimizationRespected = clauseM4_minimizationRespected(insight);
|
|
175
|
+
const scopeComplete = clauseM5_scopeComplete(insight);
|
|
176
|
+
const authorizationAllowed = clauseG1_authorizedUse(data);
|
|
177
|
+
const dutyTimingConsistent = clauseG3_dutyTimely(data);
|
|
178
|
+
const marketingProhibited = clauseG2_marketingProhibited(policy);
|
|
179
|
+
|
|
180
|
+
const checks = {
|
|
181
|
+
signatureVerifies:
|
|
182
|
+
data.integrity.verificationMode === 'trustedPrecomputedInput' &&
|
|
183
|
+
envelopeHmacSHA256 === (await hmacSha256Hex(data.integrity.secret, canonicalEnvelope)),
|
|
184
|
+
payloadHashMatches: payloadHashSHA256 === (await sha256Hex(canonicalEnvelope)),
|
|
185
|
+
minimizationRespected,
|
|
186
|
+
scopeComplete,
|
|
187
|
+
authorizationAllowed,
|
|
188
|
+
highSugarBanner: highSugarScanned,
|
|
189
|
+
alternativeLowersSugar,
|
|
190
|
+
dutyTimingConsistent,
|
|
191
|
+
marketingProhibited,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const answerSentence =
|
|
195
|
+
'The scanner is allowed to use a neutral shopping insight and recommends Low-Sugar Tea Biscuits instead of Classic Tea Biscuits.';
|
|
196
|
+
const reasonWhy = [
|
|
197
|
+
'The phone desensitizes a diabetes-related household condition into a scoped low-sugar need, wraps it in an expiring Insight+Policy envelope, and signs it.',
|
|
198
|
+
`scanned product : ${scannedProduct.name}`,
|
|
199
|
+
`suggested alternative: ${recommended?.name ?? 'none'}`,
|
|
200
|
+
`payload SHA-256 : ${payloadHashSHA256}`,
|
|
201
|
+
`HMAC-SHA256 : ${envelopeHmacSHA256}`,
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const arcLines = [
|
|
205
|
+
'=== Answer ===',
|
|
206
|
+
answerSentence,
|
|
207
|
+
'',
|
|
208
|
+
'=== Reason Why ===',
|
|
209
|
+
...reasonWhy,
|
|
210
|
+
'',
|
|
211
|
+
'=== Check ===',
|
|
212
|
+
`signature verifies : ${checks.signatureVerifies ? 'yes' : 'no'}`,
|
|
213
|
+
`payload hash matches : ${checks.payloadHashMatches ? 'yes' : 'no'}`,
|
|
214
|
+
`minimization strips sensitive terms: ${checks.minimizationRespected ? 'yes' : 'no'}`,
|
|
215
|
+
`scope complete : ${checks.scopeComplete ? 'yes' : 'no'}`,
|
|
216
|
+
`authorization allowed : ${checks.authorizationAllowed ? 'yes' : 'no'}`,
|
|
217
|
+
`high-sugar banner : ${checks.highSugarBanner ? 'yes' : 'no'}`,
|
|
218
|
+
`alternative lowers sugar : ${checks.alternativeLowersSugar ? 'yes' : 'no'}`,
|
|
219
|
+
`duty timing consistent : ${checks.dutyTimingConsistent ? 'yes' : 'no'}`,
|
|
220
|
+
`marketing prohibited : ${checks.marketingProhibited ? 'yes' : 'no'}`,
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
caseName: data.caseName,
|
|
225
|
+
derived: {
|
|
226
|
+
needsLowSugar,
|
|
227
|
+
highSugarScanned,
|
|
228
|
+
lowerSugarCandidateIds: candidates.map((product) => product.id),
|
|
229
|
+
recommendedAlternativeId: recommended?.id ?? null,
|
|
230
|
+
recommendedAlternativeName: recommended?.name ?? null,
|
|
231
|
+
alternativeLowersSugar,
|
|
232
|
+
},
|
|
233
|
+
envelope: { insight, policy },
|
|
234
|
+
integrity: {
|
|
235
|
+
canonicalEnvelope,
|
|
236
|
+
payloadHashSHA256,
|
|
237
|
+
envelopeHmacSHA256,
|
|
238
|
+
verificationMode: data.integrity.verificationMode,
|
|
239
|
+
},
|
|
240
|
+
answer: {
|
|
241
|
+
sentence: answerSentence,
|
|
242
|
+
scannedProduct: scannedProduct.name,
|
|
243
|
+
suggestedAlternative: recommended?.name ?? null,
|
|
244
|
+
payloadHashSHA256,
|
|
245
|
+
envelopeHmacSHA256,
|
|
246
|
+
},
|
|
247
|
+
reasonWhy,
|
|
248
|
+
checks,
|
|
249
|
+
allChecksPass: Object.values(checks).every(Boolean),
|
|
250
|
+
arcText: arcLines.join('\n'),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function main() {
|
|
255
|
+
const inputPath = resolve(process.argv[2] ?? resolve(__dirname, 'delfour.data.json'));
|
|
256
|
+
const data = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
257
|
+
const result = await evaluate(data);
|
|
258
|
+
|
|
259
|
+
if (process.argv.includes('--json')) {
|
|
260
|
+
console.log(JSON.stringify(result, null, 2));
|
|
261
|
+
} else {
|
|
262
|
+
console.log(result.arcText);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!result.allChecksPass) process.exitCode = 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
269
|
+
main().catch((error) => {
|
|
270
|
+
console.error(error.message);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Delfour — ARC Specification
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
This document is the **normative specification** for the Delfour case. The file `delfour.model.mjs` is the **reference ECMAScript implementation** of these clauses. The file `delfour.data.json` is the **instance** evaluated in this bundle. The file `delfour.expected.json` is the **conformance vector** for that instance.
|
|
6
|
+
|
|
7
|
+
## Aha
|
|
8
|
+
|
|
9
|
+
The scanner does not need the diagnosis. It only needs the right shopping conclusion.
|
|
10
|
+
|
|
11
|
+
A household-level medical condition remains private on the phone. What crosses the store boundary is a narrow, signed, expiring shopping insight: prefer lower-sugar products while scanning. The scanner receives enough truth to help with the purchase, but not enough detail to infer or reuse the underlying condition for marketing.
|
|
12
|
+
|
|
13
|
+
## Conventions
|
|
14
|
+
|
|
15
|
+
- “iff” means “if and only if”.
|
|
16
|
+
- A clause identifier such as `R1` or `M3` is normative.
|
|
17
|
+
- A conforming implementation may be written in any language, but it shall produce the same derived values and pass/fail outcomes for the supplied instance.
|
|
18
|
+
- The reference implementation uses ECMAScript because you preferred an international-standard JS language.
|
|
19
|
+
|
|
20
|
+
## Vocabulary
|
|
21
|
+
|
|
22
|
+
**V1. Household condition** is a private fact local to the phone.
|
|
23
|
+
|
|
24
|
+
**V2. Low-sugar need** is a neutral shopping need derived from the household condition.
|
|
25
|
+
|
|
26
|
+
**V3. Scanned product** is the product presently under consideration by the store self-scanner.
|
|
27
|
+
|
|
28
|
+
**V4. Candidate alternative** is a catalog product considered as a possible substitute.
|
|
29
|
+
|
|
30
|
+
**V5. Insight envelope** is the ordered pair `(insight, policy)` together with integrity metadata.
|
|
31
|
+
|
|
32
|
+
## Input instance
|
|
33
|
+
|
|
34
|
+
**I1.** The retailer is `Delfour`.
|
|
35
|
+
|
|
36
|
+
**I2.** The household condition is `Diabetes`.
|
|
37
|
+
|
|
38
|
+
**I3.** The scanned product is `Classic Tea Biscuits`.
|
|
39
|
+
|
|
40
|
+
**I4.** The sugar threshold is `10.0` grams per serving.
|
|
41
|
+
|
|
42
|
+
**I5.** The catalog contains the four products listed in `delfour.data.json`.
|
|
43
|
+
|
|
44
|
+
## Derivation clauses
|
|
45
|
+
|
|
46
|
+
**R1. NeedsLowSugar.** `NeedsLowSugar` holds iff the household condition is `Diabetes`.
|
|
47
|
+
|
|
48
|
+
**R2. HighSugarScanned.** `HighSugarScanned` holds iff the scanned product has `sugarPerServing ≥ 10.0`.
|
|
49
|
+
|
|
50
|
+
**R3. LowerSugarCandidate(p).** For a product `p`, `LowerSugarCandidate(p)` holds iff `p.sugarTenths < scannedProduct.sugarTenths`.
|
|
51
|
+
|
|
52
|
+
**R4. RecommendedAlternative.** `RecommendedAlternative` is the candidate product with minimum `sugarTenths` among all products `p` such that `LowerSugarCandidate(p)` holds.
|
|
53
|
+
|
|
54
|
+
**R5. AlternativeLowersSugar.** `AlternativeLowersSugar` holds iff the recommended alternative exists and has strictly lower `sugarTenths` than the scanned product.
|
|
55
|
+
|
|
56
|
+
## Governance clauses
|
|
57
|
+
|
|
58
|
+
**G1. AuthorizedUse.** `AuthorizedUse` holds iff:
|
|
59
|
+
|
|
60
|
+
1. the requested action is `odrl:use`;
|
|
61
|
+
2. the requested purpose is `shopping_assist`; and
|
|
62
|
+
3. the authorization time is not later than the expiry time.
|
|
63
|
+
|
|
64
|
+
**G2. MarketingProhibited.** `MarketingProhibited` holds iff the policy prohibits distribution for purpose `marketing`.
|
|
65
|
+
|
|
66
|
+
**G3. DutyTimely.** `DutyTimely` holds iff the duty-performance time is not later than the expiry time.
|
|
67
|
+
|
|
68
|
+
## Integrity and minimization clauses
|
|
69
|
+
|
|
70
|
+
**M1. CanonicalEnvelope.** The canonical envelope string is the JSON serialization of the ordered pair `(insight, policy)` with keys emitted in this exact sequence:
|
|
71
|
+
|
|
72
|
+
- insight: `createdAt`, `expiresAt`, `id`, `metric`, `retailer`, `scopeDevice`, `scopeEvent`, `suggestionPolicy`, `threshold`, `type`
|
|
73
|
+
- policy: `duty`, `permission`, `profile`, `prohibition`, `type`
|
|
74
|
+
|
|
75
|
+
For this case, `threshold` is serialized lexically as `10.0` rather than `10`, because the integrity vector is defined over the exact envelope bytes used by the specialized Delfour driver.
|
|
76
|
+
|
|
77
|
+
**M2. PayloadHashMatches.** `PayloadHashMatches` holds iff `SHA-256(CanonicalEnvelope) = declaredPayloadHashSHA256`.
|
|
78
|
+
|
|
79
|
+
**M3. SignatureVerifies.** `SignatureVerifies` holds iff the declared HMAC verifies under the agreed verification mode.
|
|
80
|
+
|
|
81
|
+
**M4. MinimizationRespected.** `MinimizationRespected` holds iff the serialized insight contains none of the forbidden terms: `diabetes`, `medical`.
|
|
82
|
+
|
|
83
|
+
**M5. ScopeComplete.** `ScopeComplete` holds iff the insight contains `scopeDevice`, `scopeEvent`, and `expiresAt`.
|
|
84
|
+
|
|
85
|
+
## Output contract
|
|
86
|
+
|
|
87
|
+
**O1. Answer.** A conforming renderer shall expose:
|
|
88
|
+
|
|
89
|
+
- the main recommendation sentence
|
|
90
|
+
- scanned product
|
|
91
|
+
- suggested alternative
|
|
92
|
+
- payload hash
|
|
93
|
+
- envelope HMAC
|
|
94
|
+
|
|
95
|
+
**O2. Reason Why.** A conforming renderer shall explain the household-to-insight desensitization and the scoped shopping purpose.
|
|
96
|
+
|
|
97
|
+
**O3. Check.** A conforming renderer shall expose a named yes/no or PASS/FAIL outcome for each of:
|
|
98
|
+
|
|
99
|
+
- signatureVerifies
|
|
100
|
+
- payloadHashMatches
|
|
101
|
+
- minimizationRespected
|
|
102
|
+
- scopeComplete
|
|
103
|
+
- authorizationAllowed
|
|
104
|
+
- highSugarBanner
|
|
105
|
+
- alternativeLowersSugar
|
|
106
|
+
- dutyTimingConsistent
|
|
107
|
+
- marketingProhibited
|
|
108
|
+
|
|
109
|
+
## Reference outcome for this instance
|
|
110
|
+
|
|
111
|
+
For the supplied instance:
|
|
112
|
+
|
|
113
|
+
- `NeedsLowSugar = true`
|
|
114
|
+
- `HighSugarScanned = true`
|
|
115
|
+
- `RecommendedAlternative = "Low-Sugar Tea Biscuits"`
|
|
116
|
+
- `AlternativeLowersSugar = true`
|
|
117
|
+
|
|
118
|
+
The expected ARC report and integrity values are recorded in `delfour.expected.json`.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./flandor.instance.schema.json",
|
|
3
|
+
"caseName": "Flandor",
|
|
4
|
+
"region": "Flanders",
|
|
5
|
+
"question": "Is the Flemish Economic Resilience Board allowed to use a neutral macro-economic insight for regional stabilization, and if so which package should it activate for Flanders?",
|
|
6
|
+
"timestamps": {
|
|
7
|
+
"createdAt": "2026-04-08T07:00:00+00:00",
|
|
8
|
+
"expiresAt": "2026-04-08T19:00:00+00:00",
|
|
9
|
+
"authorizedAt": "2026-04-08T09:15:00+00:00",
|
|
10
|
+
"dutyPerformedAt": "2026-04-08T18:30:00+00:00"
|
|
11
|
+
},
|
|
12
|
+
"evaluationContext": {
|
|
13
|
+
"scopeDevice": "economic-resilience-board",
|
|
14
|
+
"scopeEvent": "budget-prep-window",
|
|
15
|
+
"purpose": "regional_stabilization",
|
|
16
|
+
"prohibitedReusePurpose": "firm_surveillance"
|
|
17
|
+
},
|
|
18
|
+
"thresholds": {
|
|
19
|
+
"exportOrdersIndexBelow": 90,
|
|
20
|
+
"technicalVacancyRatePctAbove": 3.9,
|
|
21
|
+
"gridCongestionHoursAbove": 11,
|
|
22
|
+
"activeNeedCountAtLeast": 3
|
|
23
|
+
},
|
|
24
|
+
"signals": {
|
|
25
|
+
"clusters": [
|
|
26
|
+
{
|
|
27
|
+
"id": "cluster:ANT_CHEM",
|
|
28
|
+
"name": "Antwerp chemicals",
|
|
29
|
+
"exportOrdersIndex": 84,
|
|
30
|
+
"energyIntensity": 92
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "cluster:GNT_MFG",
|
|
34
|
+
"name": "Ghent manufacturing",
|
|
35
|
+
"exportOrdersIndex": 87,
|
|
36
|
+
"energyIntensity": 76
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"labourMarket": {
|
|
40
|
+
"technicalVacancyRatePct": 4.6
|
|
41
|
+
},
|
|
42
|
+
"grid": {
|
|
43
|
+
"congestionHours": 19,
|
|
44
|
+
"renewableCurtailmentMWh": 240
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"budget": {
|
|
48
|
+
"windowName": "Q2 resilience window",
|
|
49
|
+
"maxMEUR": 140
|
|
50
|
+
},
|
|
51
|
+
"packages": [
|
|
52
|
+
{
|
|
53
|
+
"id": "pkg:TRAIN_070",
|
|
54
|
+
"name": "Flanders Skills Sprint",
|
|
55
|
+
"costMEUR": 70,
|
|
56
|
+
"workerCoverage": 900,
|
|
57
|
+
"gridReliefMW": 0,
|
|
58
|
+
"coversExportWeakness": false,
|
|
59
|
+
"coversSkillsStrain": true,
|
|
60
|
+
"coversGridStress": false
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "pkg:PORT_095",
|
|
64
|
+
"name": "Schelde Trade Buffer",
|
|
65
|
+
"costMEUR": 95,
|
|
66
|
+
"workerCoverage": 300,
|
|
67
|
+
"gridReliefMW": 10,
|
|
68
|
+
"coversExportWeakness": true,
|
|
69
|
+
"coversSkillsStrain": false,
|
|
70
|
+
"coversGridStress": false
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "pkg:RET_FLEX_120",
|
|
74
|
+
"name": "Flandor Retooling Pulse",
|
|
75
|
+
"costMEUR": 120,
|
|
76
|
+
"workerCoverage": 1200,
|
|
77
|
+
"gridReliefMW": 85,
|
|
78
|
+
"coversExportWeakness": true,
|
|
79
|
+
"coversSkillsStrain": true,
|
|
80
|
+
"coversGridStress": true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "pkg:CORRIDOR_165",
|
|
84
|
+
"name": "Full Corridor Shock Shield",
|
|
85
|
+
"costMEUR": 165,
|
|
86
|
+
"workerCoverage": 1600,
|
|
87
|
+
"gridReliefMW": 110,
|
|
88
|
+
"coversExportWeakness": true,
|
|
89
|
+
"coversSkillsStrain": true,
|
|
90
|
+
"coversGridStress": true
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"insightPolicy": {
|
|
94
|
+
"id": "https://example.org/insight/flandor",
|
|
95
|
+
"metric": "regional_retooling_priority",
|
|
96
|
+
"type": "ins:Insight",
|
|
97
|
+
"suggestionPolicy": "lowest_cost_package_covering_all_active_needs",
|
|
98
|
+
"policyType": "odrl:Policy",
|
|
99
|
+
"policyProfile": "Flandor-Insight-Policy"
|
|
100
|
+
},
|
|
101
|
+
"integrity": {
|
|
102
|
+
"hashAlgorithm": "SHA-256",
|
|
103
|
+
"macAlgorithm": "HMAC-SHA-256",
|
|
104
|
+
"secret": "flandor-demo-shared-secret",
|
|
105
|
+
"verificationMode": "trustedPrecomputedInput"
|
|
106
|
+
}
|
|
107
|
+
}
|