@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +47 -3
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/doctor.js +11 -4
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +71 -1
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +37 -1
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +603 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +37 -1
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +160 -1
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +175 -2
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome ↔ planning-estimate projection contract v1 (issue #340 contract slice,
|
|
3
|
+
* SIMULATED-TRIGGER DRILL — default OFF, zero callers, trigger flag frozen false).
|
|
4
|
+
*
|
|
5
|
+
* ⚠ Evidence boundary (read this before citing anything from this module):
|
|
6
|
+
* this is `fixture_contract` machinery exercised by a `simulated_trigger_drill`.
|
|
7
|
+
* #340's real trigger ("supplier protocol + WriteGate admission + authorized real
|
|
8
|
+
* order/refund authority facts") has NOT fired. Nothing here is, or may be cited
|
|
9
|
+
* as, real-order evidence; `OUTCOME_TRIGGER_FIRED` stays false and the tracker
|
|
10
|
+
* stays open. Fixtures are derived read-only from the hotelbyte fake-CLI outcome
|
|
11
|
+
* vocabulary (ts/scripts/hotelbyte-spawn-e2e-tests.ts); no real CLI, network,
|
|
12
|
+
* subprocess, credential or ledger surface is touched.
|
|
13
|
+
*
|
|
14
|
+
* Shape (mirrors ts/capabilities/fx-contract.ts #344 and ts/src/session-zones.ts P4-1):
|
|
15
|
+
* - pure functions, zero IO / timers / network; every clock is injected;
|
|
16
|
+
* - closed error-code set (`OutcomeErrorCode`), typed results, fail-closed;
|
|
17
|
+
* - the real-order ingestion seam is interface + data shape only
|
|
18
|
+
* (`ingestSupplierOutcome` refuses with `trigger_deferred` before reading any
|
|
19
|
+
* source, so zero supplier records are consulted before the trigger fires).
|
|
20
|
+
*
|
|
21
|
+
* Frozen upstream vocabulary (READ-ONLY reuse, nothing re-declared):
|
|
22
|
+
* - docs/design/write-gate-production-design.md §3 SupplierOutcome / §7 unknown
|
|
23
|
+
* reconciliation / §8 cancel ≠ refund, cancel.serviceFee ≠ customer refund,
|
|
24
|
+
* pending → compensated cancels only the local suggestion;
|
|
25
|
+
* - ts/src/booking-surface/cancel-refund-commission.ts (#233): refund authority
|
|
26
|
+
* sources, `SERVICE_FEE_SOURCE`, and the local ledger alphabet whose
|
|
27
|
+
* `compensated` word is explicitly NOT a supplier outcome;
|
|
28
|
+
* - ts/capabilities/hotelbyte-transaction.ts (#232): attempt / customerReferenceNo
|
|
29
|
+
* binding keys and the "unknown is never success" discipline;
|
|
30
|
+
* - ts/capabilities/fx-contract.ts (#344): Money / NormalizedAmount, single
|
|
31
|
+
* valuation instant comparison — an FX rate is never guessed here either.
|
|
32
|
+
*
|
|
33
|
+
* Red lines encoded structurally (each has a falsification test in
|
|
34
|
+
* ts/scripts/outcome-projection-tests.ts, run-all §85):
|
|
35
|
+
* 1. association: plan estimate ↔ immutable quote ↔ supplier attempt bind by four
|
|
36
|
+
* mandatory keys; a missing key is refused, never defaulted;
|
|
37
|
+
* 2. `unknown` / `pending` are never settled success and never zero deviation —
|
|
38
|
+
* they are counted as `indeterminate`, separately from any sample;
|
|
39
|
+
* 3. append-only + revocable: nothing is mutated or deleted; a revocation is
|
|
40
|
+
* itself an entry; replay of an idempotency key is a no-op, a key reused with
|
|
41
|
+
* a different payload is a conflict;
|
|
42
|
+
* 4. terminal states never regress (terminal → open refused; terminal → a
|
|
43
|
+
* non-progressive terminal refused), while a LATE terminal observation is
|
|
44
|
+
* accepted and flagged;
|
|
45
|
+
* 5. negative list: sensitive order fields and credentials can never enter the
|
|
46
|
+
* projection — the evidence record is key-allow-listed AND value-scanned;
|
|
47
|
+
* 6. deviation may only calibrate FUTURE estimates/ranking, within declared
|
|
48
|
+
* bounds, with an explanation; it can NEVER enter the user's hard budget
|
|
49
|
+
* verdict (`hardBudgetVerdict` admits only an `AuthoritativeTotal`).
|
|
50
|
+
*
|
|
51
|
+
* Out of scope by construction (only the real trigger can supply it): a real-order
|
|
52
|
+
* E2E, real deviation distributions, any calibration constant justified by data,
|
|
53
|
+
* and admission of the ingestion seam.
|
|
54
|
+
*
|
|
55
|
+
* @module src/outcome-projection
|
|
56
|
+
*/
|
|
57
|
+
import { makeFactId } from './bookable-facts.js';
|
|
58
|
+
import { REFUND_AUTHORITY_SOURCES, SERVICE_FEE_SOURCE, LOCAL_LEDGER_STATUSES, } from './booking-surface/cancel-refund-commission.js';
|
|
59
|
+
import { compareMoney, formatMoney, } from '../capabilities/fx-contract.js';
|
|
60
|
+
/** Versioned contract schema id (readers validate it). */
|
|
61
|
+
export const OUTCOME_PROJECTION_SCHEMA = 'gotry_outcome_projection.v1';
|
|
62
|
+
export const OUTCOME_CALIBRATION_SCHEMA = 'gotry_outcome_calibration.v1';
|
|
63
|
+
/**
|
|
64
|
+
* #340 implementation trigger gate, frozen false. Flipping it true requires the
|
|
65
|
+
* supplier protocol + WriteGate production admission + authorized real order /
|
|
66
|
+
* refund authority facts (issue #340 启动条件). Fixtures, model self-reports and
|
|
67
|
+
* "booking attempts" explicitly do not count.
|
|
68
|
+
*/
|
|
69
|
+
export const OUTCOME_TRIGGER_FIRED = false;
|
|
70
|
+
/** Bounded projection: an unbounded outcome log is a memory-face defect, not a fact. */
|
|
71
|
+
export const MAX_PROJECTION_ENTRIES = 256;
|
|
72
|
+
/** Bounded free text: a reason/explanation is a label, never a transcript. */
|
|
73
|
+
export const MAX_REASON_CHARS = 200;
|
|
74
|
+
function fail(code, detail) {
|
|
75
|
+
return { ok: false, code, detail };
|
|
76
|
+
}
|
|
77
|
+
function pass(value) {
|
|
78
|
+
return { ok: true, value };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Supplier final outcome alphabet (write-gate-production-design §3 subset plus the
|
|
82
|
+
* mandatory `unknown`). `unknown` exists precisely so that a timeout / killed
|
|
83
|
+
* process / unparseable response has a home that is neither success nor failure.
|
|
84
|
+
*/
|
|
85
|
+
export const OUTCOME_STATUSES = ['pending', 'confirmed', 'failed', 'cancelled', 'refunded', 'unknown'];
|
|
86
|
+
/** Open (non-terminal) statuses: no settlement conclusion may be drawn from them. */
|
|
87
|
+
export const OPEN_OUTCOME_STATUSES = ['pending', 'unknown'];
|
|
88
|
+
/** Terminal statuses: a terminal never regresses to an open status. */
|
|
89
|
+
export const TERMINAL_OUTCOME_STATUSES = ['confirmed', 'failed', 'cancelled', 'refunded'];
|
|
90
|
+
export function isOutcomeStatus(v) {
|
|
91
|
+
return typeof v === 'string' && OUTCOME_STATUSES.includes(v);
|
|
92
|
+
}
|
|
93
|
+
export function isTerminalOutcomeStatus(v) {
|
|
94
|
+
return typeof v === 'string' && TERMINAL_OUTCOME_STATUSES.includes(v);
|
|
95
|
+
}
|
|
96
|
+
export function statusTerminality(status) {
|
|
97
|
+
return isTerminalOutcomeStatus(status) ? 'terminal' : 'open';
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Settled SUCCESS is `confirmed` and nothing else. In particular `unknown` (the
|
|
101
|
+
* timeout / indeterminate bucket) is never a sale — #340 acceptance 2.
|
|
102
|
+
*/
|
|
103
|
+
export function countsAsSettledSuccess(status) {
|
|
104
|
+
return status === 'confirmed';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Deviation comparability. Only `confirmed` pairs a planning estimate with a
|
|
108
|
+
* supplier final charge. `pending`/`unknown` are indeterminate (never zero
|
|
109
|
+
* deviation); `failed`/`cancelled`/`refunded` have no comparable final charge —
|
|
110
|
+
* their money face is the independent aftercare surface of #233.
|
|
111
|
+
*/
|
|
112
|
+
export function comparableForDeviation(status) {
|
|
113
|
+
return status === 'confirmed';
|
|
114
|
+
}
|
|
115
|
+
/** Frozen terminal lifecycle DAG: which terminal may follow which terminal. */
|
|
116
|
+
export const TERMINAL_PROGRESSIONS = {
|
|
117
|
+
confirmed: ['cancelled', 'refunded'],
|
|
118
|
+
cancelled: ['refunded'],
|
|
119
|
+
failed: [],
|
|
120
|
+
refunded: [],
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Transition admission. `idempotent` = the same status re-observed (a replay of
|
|
124
|
+
* the world, not of the key). Terminal → open and terminal → unreachable terminal
|
|
125
|
+
* are refused with evidence; an open → terminal arriving late is `progress`.
|
|
126
|
+
*/
|
|
127
|
+
export function transitionAllowed(from, to) {
|
|
128
|
+
if (!isOutcomeStatus(from) || !isOutcomeStatus(to)) {
|
|
129
|
+
return fail('closed_set', `transition endpoints must be in [${OUTCOME_STATUSES.join('/')}], got ${JSON.stringify(from)}→${JSON.stringify(to)}`);
|
|
130
|
+
}
|
|
131
|
+
if (from === to)
|
|
132
|
+
return pass('idempotent');
|
|
133
|
+
if (statusTerminality(from) === 'open')
|
|
134
|
+
return pass('progress');
|
|
135
|
+
if (statusTerminality(to) === 'open') {
|
|
136
|
+
return fail('terminal_regression', `terminal ${from} must not regress to open ${to} — a later probe failure is not evidence that a settled outcome unsettled (write-gate §7: 终态后反证只置冲突交人工)`);
|
|
137
|
+
}
|
|
138
|
+
const reachable = TERMINAL_PROGRESSIONS[from];
|
|
139
|
+
if (reachable.includes(to))
|
|
140
|
+
return pass('progress');
|
|
141
|
+
return fail('terminal_conflict', `terminal ${from} does not reach terminal ${to} in the frozen lifecycle (reachable: [${reachable.join('/') || 'none'}]) — explicit conflict for human reconciliation, never a silent flip`);
|
|
142
|
+
}
|
|
143
|
+
function requiredKey(label, value) {
|
|
144
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
145
|
+
return fail('binding_incomplete', `association key ${label} must be a non-empty string, got ${JSON.stringify(value)} (no default, no derivation from a sibling key)`);
|
|
146
|
+
}
|
|
147
|
+
return pass(value.trim());
|
|
148
|
+
}
|
|
149
|
+
export function associationKey(input) {
|
|
150
|
+
const plan = requiredKey('planEstimateId', input?.planEstimateId);
|
|
151
|
+
if (!plan.ok)
|
|
152
|
+
return plan;
|
|
153
|
+
const quote = requiredKey('quoteId', input?.quoteId);
|
|
154
|
+
if (!quote.ok)
|
|
155
|
+
return quote;
|
|
156
|
+
const attempt = requiredKey('attemptId', input?.attemptId);
|
|
157
|
+
if (!attempt.ok)
|
|
158
|
+
return attempt;
|
|
159
|
+
const intent = requiredKey('intentIdemKey', input?.intentIdemKey);
|
|
160
|
+
if (!intent.ok)
|
|
161
|
+
return intent;
|
|
162
|
+
return pass(Object.freeze({
|
|
163
|
+
planEstimateId: plan.value,
|
|
164
|
+
quoteId: quote.value,
|
|
165
|
+
attemptId: attempt.value,
|
|
166
|
+
intentIdemKey: intent.value,
|
|
167
|
+
projectionKey: makeFactId(['outcome-projection', plan.value, quote.value, attempt.value, intent.value]),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 3. Time basis and the negative list
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
/** UTC instant parse + normalization (fx-contract discipline: date-only is not an instant). */
|
|
174
|
+
export function parseObservationInstant(value, label) {
|
|
175
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
176
|
+
return fail('bad_ts', `${label} must be a non-empty ISO-8601 UTC instant, got ${JSON.stringify(value)}`);
|
|
177
|
+
}
|
|
178
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
|
|
179
|
+
return fail('bad_ts', `${label} must carry a full time-of-day (date-only '${value}' cannot order observations)`);
|
|
180
|
+
}
|
|
181
|
+
const ms = Date.parse(value);
|
|
182
|
+
if (!Number.isFinite(ms))
|
|
183
|
+
return fail('bad_ts', `${label} is not a parseable ISO-8601 instant: ${JSON.stringify(value)}`);
|
|
184
|
+
return pass(new Date(ms).toISOString());
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Evidence key allow-list (closed set). Anything else — including a field that
|
|
188
|
+
* merely looks harmless — is refused: the projection carries digests and pointers,
|
|
189
|
+
* never order payloads. Shape discipline mirrors session-zones' write-side list.
|
|
190
|
+
*/
|
|
191
|
+
export const OUTCOME_EVIDENCE_ALLOWED_KEYS = [
|
|
192
|
+
'supplier_status_word',
|
|
193
|
+
'supplier_order_ref_digest',
|
|
194
|
+
'customer_reference_digest',
|
|
195
|
+
'probe_digest',
|
|
196
|
+
'request_fingerprint_sha256',
|
|
197
|
+
'approval_receipt_digest',
|
|
198
|
+
'refund_evidence_kind',
|
|
199
|
+
'refund_evidence_digest',
|
|
200
|
+
'amount_source',
|
|
201
|
+
'classification',
|
|
202
|
+
];
|
|
203
|
+
/** Explicitly named forbidden keys, reported by name so the refusal is actionable. */
|
|
204
|
+
export const OUTCOME_EVIDENCE_FORBIDDEN_KEYS = [
|
|
205
|
+
'guest_name', 'guestName', 'passenger_name', 'passengerName', 'contact_name',
|
|
206
|
+
'passport', 'passport_no', 'id_card', 'idCard', 'national_id',
|
|
207
|
+
'phone', 'mobile', 'email', 'address', 'postal_code',
|
|
208
|
+
'card_number', 'cardNumber', 'pan', 'cvv', 'expiry',
|
|
209
|
+
'password', 'api_key', 'apiKey', 'secret', 'token', 'authorization', 'cookie',
|
|
210
|
+
'raw_payload', 'rawPayload', 'raw_response', 'rawResponse', 'stdout', 'stderr',
|
|
211
|
+
];
|
|
212
|
+
/** Value-shape scan (defense in depth behind the key allow-list). */
|
|
213
|
+
const OUTCOME_VALUE_NEGATIVE_PATTERNS = [
|
|
214
|
+
{ re: /(护照|身份证|证件号|通行证)\s*号?\s*[::]?\s*\w+/i, label: 'identity document field' },
|
|
215
|
+
{ re: /[\w.+-]+@[\w-]+\.[\w.-]+/, label: 'email address' },
|
|
216
|
+
{ re: /https?:\/\/\S+/i, label: 'URL' },
|
|
217
|
+
{ re: /(?:api[_-]?key|secret|token|password|passwd)\s*[:=]\s*\S+/i, label: 'credential shape' },
|
|
218
|
+
{ re: /\bbearer\s+[A-Za-z0-9._-]{8,}/i, label: 'credential shape' },
|
|
219
|
+
{ re: /\bsk-[A-Za-z0-9]{12,}/, label: 'credential shape' },
|
|
220
|
+
];
|
|
221
|
+
/**
|
|
222
|
+
* Numeric-identifier patterns, anchored on both sides and scanned PER LEAF VALUE.
|
|
223
|
+
*
|
|
224
|
+
* Both halves of that sentence are load-bearing:
|
|
225
|
+
*
|
|
226
|
+
* - **Anchored**: the allow-listed `*_digest` fields legitimately carry SHA-family
|
|
227
|
+
* hex, and a run of fifteen or more decimal digits occurs inside a random 64-char
|
|
228
|
+
* digest a few percent of the time. An unanchored `\d{15,}` refuses those
|
|
229
|
+
* legitimate digests. Requiring a non-alphanumeric boundary means a digit run
|
|
230
|
+
* embedded in a longer alphanumeric token is not an identifier, while a STANDALONE
|
|
231
|
+
* run still is — at any length, since ID cards are 18 and card numbers up to 19.
|
|
232
|
+
* - **Per leaf**: scanning the canonical JSON instead would defeat the anchors,
|
|
233
|
+
* because the surrounding `"` quotes are themselves non-alphanumeric boundaries.
|
|
234
|
+
*
|
|
235
|
+
* A pure-decimal token is deliberately NOT given a digest exemption: it is
|
|
236
|
+
* indistinguishable from an identifier, so it stays fail-closed.
|
|
237
|
+
*/
|
|
238
|
+
const OUTCOME_NUMERIC_ID_PATTERNS = [
|
|
239
|
+
{ re: /(?<![0-9A-Za-z])\d{15,}[Xx]?(?![0-9A-Za-z])/, label: 'suspected document/card number' },
|
|
240
|
+
{ re: /(?<![0-9A-Za-z])1[3-9]\d{9}(?![0-9A-Za-z])/, label: 'suspected mainland mobile number' },
|
|
241
|
+
];
|
|
242
|
+
/** Collect every scalar leaf as the string the scan should see. */
|
|
243
|
+
function collectScalarLeaves(value, out, depth = 0) {
|
|
244
|
+
if (depth > 5)
|
|
245
|
+
return;
|
|
246
|
+
if (typeof value === 'string') {
|
|
247
|
+
out.push(value);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
|
|
251
|
+
out.push(String(value));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (value === null || typeof value !== 'object')
|
|
255
|
+
return;
|
|
256
|
+
if (Array.isArray(value)) {
|
|
257
|
+
for (const item of value)
|
|
258
|
+
collectScalarLeaves(item, out, depth + 1);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const item of Object.values(value))
|
|
262
|
+
collectScalarLeaves(item, out, depth + 1);
|
|
263
|
+
}
|
|
264
|
+
/** Deterministic canonical JSON (sorted keys); cycles / depth / undefined → null. */
|
|
265
|
+
export function canonicalOutcomeJson(v, depth = 0, seen = new Set()) {
|
|
266
|
+
if (v === undefined || typeof v === 'function')
|
|
267
|
+
return null;
|
|
268
|
+
if (typeof v === 'bigint')
|
|
269
|
+
return JSON.stringify(v.toString());
|
|
270
|
+
if (v === null || typeof v !== 'object')
|
|
271
|
+
return JSON.stringify(v) ?? 'null';
|
|
272
|
+
if (depth > 5 || seen.has(v))
|
|
273
|
+
return null;
|
|
274
|
+
seen.add(v);
|
|
275
|
+
if (Array.isArray(v)) {
|
|
276
|
+
const parts = [];
|
|
277
|
+
for (const x of v) {
|
|
278
|
+
const p = canonicalOutcomeJson(x, depth + 1, seen);
|
|
279
|
+
if (p === null)
|
|
280
|
+
return null;
|
|
281
|
+
parts.push(p);
|
|
282
|
+
}
|
|
283
|
+
return `[${parts.join(',')}]`;
|
|
284
|
+
}
|
|
285
|
+
const obj = v;
|
|
286
|
+
const parts = [];
|
|
287
|
+
for (const k of Object.keys(obj).sort()) {
|
|
288
|
+
const p = canonicalOutcomeJson(obj[k], depth + 1, seen);
|
|
289
|
+
if (p === null)
|
|
290
|
+
return null;
|
|
291
|
+
parts.push(`${JSON.stringify(k)}:${p}`);
|
|
292
|
+
}
|
|
293
|
+
return `{${parts.join(',')}}`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Negative-list gate for the evidence record. Returns a refusal reason, or null.
|
|
297
|
+
* Order matters for diagnosability: a named forbidden key is reported as such
|
|
298
|
+
* before the generic "not in the allow-list" message.
|
|
299
|
+
*/
|
|
300
|
+
export function outcomeEvidenceViolation(evidence) {
|
|
301
|
+
if (evidence === null || typeof evidence !== 'object' || Array.isArray(evidence)) {
|
|
302
|
+
return 'evidence must be a plain object of allow-listed digest/pointer fields';
|
|
303
|
+
}
|
|
304
|
+
const record = evidence;
|
|
305
|
+
for (const key of Object.keys(record)) {
|
|
306
|
+
if (OUTCOME_EVIDENCE_FORBIDDEN_KEYS.includes(key)) {
|
|
307
|
+
return `negative list: sensitive order field '${key}' never enters the projection (digests and pointers only)`;
|
|
308
|
+
}
|
|
309
|
+
if (!OUTCOME_EVIDENCE_ALLOWED_KEYS.includes(key)) {
|
|
310
|
+
return `negative list: key '${key}' is not in the evidence allow-list [${OUTCOME_EVIDENCE_ALLOWED_KEYS.join('/')}] — extend the closed set via PR, never by passing data through`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const canonical = canonicalOutcomeJson(record);
|
|
314
|
+
if (canonical === null)
|
|
315
|
+
return 'evidence must be finite, acyclic, bounded-depth and free of undefined values';
|
|
316
|
+
for (const { re, label } of OUTCOME_VALUE_NEGATIVE_PATTERNS) {
|
|
317
|
+
if (re.test(canonical))
|
|
318
|
+
return `negative list: ${label} detected in an evidence value (digests and pointers only)`;
|
|
319
|
+
}
|
|
320
|
+
const leaves = [];
|
|
321
|
+
collectScalarLeaves(record, leaves);
|
|
322
|
+
for (const leaf of leaves) {
|
|
323
|
+
for (const { re, label } of OUTCOME_NUMERIC_ID_PATTERNS) {
|
|
324
|
+
if (re.test(leaf))
|
|
325
|
+
return `negative list: ${label} detected in an evidence value (digests and pointers only)`;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// 4. Append-only, revocable projection
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
export const PROJECTION_ENTRY_KINDS = ['outcome_observed', 'revocation'];
|
|
334
|
+
export function emptyProjection(key) {
|
|
335
|
+
return Object.freeze({ schema: OUTCOME_PROJECTION_SCHEMA, key, entries: Object.freeze([]) });
|
|
336
|
+
}
|
|
337
|
+
function boundedText(label, value) {
|
|
338
|
+
if (value === undefined)
|
|
339
|
+
return pass(undefined);
|
|
340
|
+
if (typeof value !== 'string')
|
|
341
|
+
return fail('closed_set', `${label} must be a string when present, got ${JSON.stringify(value)}`);
|
|
342
|
+
if (value.length > MAX_REASON_CHARS)
|
|
343
|
+
return fail('bound_exceeded', `${label} exceeds ${MAX_REASON_CHARS} chars (${value.length}) — a label, never a transcript`);
|
|
344
|
+
return pass(value);
|
|
345
|
+
}
|
|
346
|
+
function amountOf(observation, status) {
|
|
347
|
+
const amount = observation.amount;
|
|
348
|
+
if (amount === undefined) {
|
|
349
|
+
if (status === 'refunded') {
|
|
350
|
+
return fail('evidence_required', 'terminal refunded requires an authoritative refund amount (#233: refunded only from supplier/wallet refund records)');
|
|
351
|
+
}
|
|
352
|
+
return pass(undefined);
|
|
353
|
+
}
|
|
354
|
+
const money = amount.money;
|
|
355
|
+
if (money === null || typeof money !== 'object' || typeof money.currency !== 'string' || typeof money.amountMinor !== 'bigint') {
|
|
356
|
+
return fail('closed_set', 'amount.money must be an fx-contract MoneyAmount (currency + bigint minor unit); floats are refused');
|
|
357
|
+
}
|
|
358
|
+
if (typeof amount.source !== 'string' || amount.source.trim() === '') {
|
|
359
|
+
return fail('binding_incomplete', 'amount.source must name where the number is authoritative from');
|
|
360
|
+
}
|
|
361
|
+
if (status === 'refunded') {
|
|
362
|
+
if (amount.source === SERVICE_FEE_SOURCE) {
|
|
363
|
+
return fail('service_fee_as_refund', `the supplier cancel service fee (${SERVICE_FEE_SOURCE}) is a separate disclosure and is never the customer refund amount (#233 / write-gate §8)`);
|
|
364
|
+
}
|
|
365
|
+
if (!REFUND_AUTHORITY_SOURCES.includes(amount.source)) {
|
|
366
|
+
return fail('evidence_required', `refund amount source must be one of [${REFUND_AUTHORITY_SOURCES.join('/')}], got ${JSON.stringify(amount.source)}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return pass({ currency: money.currency, amount_minor: money.amountMinor.toString(), source: amount.source });
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Append one supplier observation (pure; returns a new projection).
|
|
373
|
+
*
|
|
374
|
+
* Guards, in order: trigger-independent shape → closed status set → local ledger
|
|
375
|
+
* word rejection → clock sanity → negative list → idempotency → transition.
|
|
376
|
+
*/
|
|
377
|
+
export function appendOutcome(projection, observation, now) {
|
|
378
|
+
if (projection?.schema !== OUTCOME_PROJECTION_SCHEMA) {
|
|
379
|
+
return fail('closed_set', `projection schema must be ${OUTCOME_PROJECTION_SCHEMA}`);
|
|
380
|
+
}
|
|
381
|
+
if (projection.entries.length >= MAX_PROJECTION_ENTRIES) {
|
|
382
|
+
return fail('bound_exceeded', `projection already holds ${projection.entries.length} entries (bound ${MAX_PROJECTION_ENTRIES}); an unbounded outcome log is a defect`);
|
|
383
|
+
}
|
|
384
|
+
const nowParsed = parseObservationInstant(now, 'injected clock now');
|
|
385
|
+
if (!nowParsed.ok)
|
|
386
|
+
return nowParsed;
|
|
387
|
+
if (typeof observation?.status === 'string' && LOCAL_LEDGER_STATUSES.includes(observation.status) && !isOutcomeStatus(observation.status)) {
|
|
388
|
+
return fail('local_ledger_status_not_supplier_outcome', `'${observation.status}' is a LOCAL ledger word (ADR-17 alphabet [${LOCAL_LEDGER_STATUSES.join('/')}]): compensating a local suggestion is not a supplier outcome and never means refunded (write-gate §8)`);
|
|
389
|
+
}
|
|
390
|
+
if (!isOutcomeStatus(observation?.status)) {
|
|
391
|
+
return fail('closed_set', `outcome status must be one of [${OUTCOME_STATUSES.join('/')}], got ${JSON.stringify(observation?.status)}`);
|
|
392
|
+
}
|
|
393
|
+
const status = observation.status;
|
|
394
|
+
const idem = requiredKey('idemKey', observation.idemKey);
|
|
395
|
+
if (!idem.ok)
|
|
396
|
+
return fail('binding_incomplete', idem.detail);
|
|
397
|
+
const observedAt = parseObservationInstant(observation.observedAt, 'observation observed_at');
|
|
398
|
+
if (!observedAt.ok)
|
|
399
|
+
return observedAt;
|
|
400
|
+
if (observedAt.value > nowParsed.value) {
|
|
401
|
+
return fail('bad_ts', `observed_at ${observedAt.value} postdates the injected clock ${nowParsed.value} — impossible provenance`);
|
|
402
|
+
}
|
|
403
|
+
const reason = boundedText('reason', observation.reason);
|
|
404
|
+
if (!reason.ok)
|
|
405
|
+
return reason;
|
|
406
|
+
const evidence = observation.evidence ?? {};
|
|
407
|
+
const violation = outcomeEvidenceViolation(evidence);
|
|
408
|
+
if (violation !== null)
|
|
409
|
+
return fail('negative_list', violation);
|
|
410
|
+
const amount = amountOf(observation, status);
|
|
411
|
+
if (!amount.ok)
|
|
412
|
+
return amount;
|
|
413
|
+
const payloadDigest = makeFactId([
|
|
414
|
+
'outcome-observed',
|
|
415
|
+
status,
|
|
416
|
+
observedAt.value,
|
|
417
|
+
amount.value ? `${amount.value.currency}:${amount.value.amount_minor}:${amount.value.source}` : '',
|
|
418
|
+
canonicalOutcomeJson(evidence) ?? '',
|
|
419
|
+
reason.value ?? '',
|
|
420
|
+
]);
|
|
421
|
+
const prior = projection.entries.find(e => e.idem_key === idem.value);
|
|
422
|
+
if (prior !== undefined) {
|
|
423
|
+
if (prior.payload_digest === payloadDigest) {
|
|
424
|
+
return pass({ projection, appended: false, lateObservation: false, transition: 'idempotent' });
|
|
425
|
+
}
|
|
426
|
+
return fail('idempotency_conflict', `idempotency key ${idem.value} already carries payload ${prior.payload_digest} (seq ${prior.seq}); a different payload ${payloadDigest} under the same key is a conflict, never an overwrite`);
|
|
427
|
+
}
|
|
428
|
+
const view = foldProjection(projection);
|
|
429
|
+
const from = view.status ?? 'unknown';
|
|
430
|
+
const transition = view.status === null ? pass('progress') : transitionAllowed(from, status);
|
|
431
|
+
if (!transition.ok)
|
|
432
|
+
return transition;
|
|
433
|
+
const seq = projection.entries.length;
|
|
434
|
+
const entry = Object.freeze({
|
|
435
|
+
schema: OUTCOME_PROJECTION_SCHEMA,
|
|
436
|
+
entry_id: makeFactId([projection.key.projectionKey, String(seq), payloadDigest]),
|
|
437
|
+
seq,
|
|
438
|
+
kind: 'outcome_observed',
|
|
439
|
+
idem_key: idem.value,
|
|
440
|
+
observed_at: observedAt.value,
|
|
441
|
+
appended_at: nowParsed.value,
|
|
442
|
+
payload_digest: payloadDigest,
|
|
443
|
+
status,
|
|
444
|
+
...(amount.value !== undefined ? { amount: amount.value } : {}),
|
|
445
|
+
evidence: Object.freeze({ ...evidence }),
|
|
446
|
+
...(reason.value !== undefined ? { reason: reason.value } : {}),
|
|
447
|
+
});
|
|
448
|
+
const lateObservation = view.lastObservedAt !== null && observedAt.value < view.lastObservedAt;
|
|
449
|
+
return pass({
|
|
450
|
+
projection: Object.freeze({ ...projection, entries: Object.freeze([...projection.entries, entry]) }),
|
|
451
|
+
appended: true,
|
|
452
|
+
entry,
|
|
453
|
+
lateObservation,
|
|
454
|
+
transition: transition.value,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Revoke one entry (append-only: a revocation is itself an entry; nothing is
|
|
459
|
+
* deleted or mutated). A revocation needs its own idempotency key, a bounded
|
|
460
|
+
* reason and an actor; revoking a missing or already-revoked entry is refused.
|
|
461
|
+
*/
|
|
462
|
+
export function revokeEntry(projection, input, now) {
|
|
463
|
+
if (projection.entries.length >= MAX_PROJECTION_ENTRIES) {
|
|
464
|
+
return fail('bound_exceeded', `projection already holds ${projection.entries.length} entries (bound ${MAX_PROJECTION_ENTRIES})`);
|
|
465
|
+
}
|
|
466
|
+
const nowParsed = parseObservationInstant(now, 'injected clock now');
|
|
467
|
+
if (!nowParsed.ok)
|
|
468
|
+
return nowParsed;
|
|
469
|
+
const target = requiredKey('targetEntryId', input?.targetEntryId);
|
|
470
|
+
if (!target.ok)
|
|
471
|
+
return fail('binding_incomplete', target.detail);
|
|
472
|
+
const actor = requiredKey('actor', input?.actor);
|
|
473
|
+
if (!actor.ok)
|
|
474
|
+
return fail('binding_incomplete', actor.detail);
|
|
475
|
+
const idem = requiredKey('idemKey', input?.idemKey);
|
|
476
|
+
if (!idem.ok)
|
|
477
|
+
return fail('binding_incomplete', idem.detail);
|
|
478
|
+
const reasonText = requiredKey('reason', input?.reason);
|
|
479
|
+
if (!reasonText.ok)
|
|
480
|
+
return fail('binding_incomplete', `${reasonText.detail} (a revocation without a stated reason is not auditable)`);
|
|
481
|
+
const reason = boundedText('reason', reasonText.value);
|
|
482
|
+
if (!reason.ok)
|
|
483
|
+
return reason;
|
|
484
|
+
const observed = projection.entries.find(e => e.entry_id === target.value && e.kind === 'outcome_observed');
|
|
485
|
+
if (observed === undefined) {
|
|
486
|
+
return fail('unknown_entry', `no observed entry ${target.value} in projection ${projection.key.projectionKey}`);
|
|
487
|
+
}
|
|
488
|
+
if (projection.entries.some(e => e.kind === 'revocation' && e.revokes === target.value)) {
|
|
489
|
+
return fail('unknown_entry', `entry ${target.value} is already revoked; a second revocation is not an additional fact`);
|
|
490
|
+
}
|
|
491
|
+
if (projection.entries.some(e => e.idem_key === idem.value)) {
|
|
492
|
+
return fail('idempotency_conflict', `idempotency key ${idem.value} is already used in this projection`);
|
|
493
|
+
}
|
|
494
|
+
const seq = projection.entries.length;
|
|
495
|
+
const payloadDigest = makeFactId(['revocation', target.value, actor.value, reason.value ?? '']);
|
|
496
|
+
const entry = Object.freeze({
|
|
497
|
+
schema: OUTCOME_PROJECTION_SCHEMA,
|
|
498
|
+
entry_id: makeFactId([projection.key.projectionKey, String(seq), payloadDigest]),
|
|
499
|
+
seq,
|
|
500
|
+
kind: 'revocation',
|
|
501
|
+
idem_key: idem.value,
|
|
502
|
+
observed_at: nowParsed.value,
|
|
503
|
+
appended_at: nowParsed.value,
|
|
504
|
+
payload_digest: payloadDigest,
|
|
505
|
+
revokes: target.value,
|
|
506
|
+
reason: `${actor.value}: ${reason.value ?? ''}`.slice(0, MAX_REASON_CHARS),
|
|
507
|
+
});
|
|
508
|
+
return pass({
|
|
509
|
+
projection: Object.freeze({ ...projection, entries: Object.freeze([...projection.entries, entry]) }),
|
|
510
|
+
entry,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Deterministic fold: entries in append order, revoked observations skipped.
|
|
515
|
+
* Re-folding a projection always reproduces the same view (replay == direct read).
|
|
516
|
+
*/
|
|
517
|
+
export function foldProjection(projection) {
|
|
518
|
+
const revoked = new Set();
|
|
519
|
+
for (const e of projection.entries) {
|
|
520
|
+
if (e.kind === 'revocation' && typeof e.revokes === 'string')
|
|
521
|
+
revoked.add(e.revokes);
|
|
522
|
+
}
|
|
523
|
+
let status = null;
|
|
524
|
+
let lastObservedAt = null;
|
|
525
|
+
const effective = [];
|
|
526
|
+
const conflicts = [];
|
|
527
|
+
for (const e of projection.entries) {
|
|
528
|
+
if (e.kind !== 'outcome_observed' || e.status === undefined)
|
|
529
|
+
continue;
|
|
530
|
+
if (revoked.has(e.entry_id))
|
|
531
|
+
continue;
|
|
532
|
+
if (status !== null) {
|
|
533
|
+
const t = transitionAllowed(status, e.status);
|
|
534
|
+
if (!t.ok) {
|
|
535
|
+
conflicts.push(e.entry_id);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
status = e.status;
|
|
540
|
+
lastObservedAt = e.observed_at;
|
|
541
|
+
effective.push(e.entry_id);
|
|
542
|
+
}
|
|
543
|
+
return Object.freeze({
|
|
544
|
+
status,
|
|
545
|
+
terminal: status !== null && statusTerminality(status) === 'terminal',
|
|
546
|
+
settledSuccess: status !== null && countsAsSettledSuccess(status),
|
|
547
|
+
indeterminate: status === null || statusTerminality(status) === 'open',
|
|
548
|
+
lastObservedAt,
|
|
549
|
+
effectiveEntryIds: Object.freeze([...effective]),
|
|
550
|
+
revokedEntryIds: Object.freeze([...revoked]),
|
|
551
|
+
foldConflicts: Object.freeze([...conflicts]),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Deviation of a confirmed outcome against its planning estimate.
|
|
556
|
+
*
|
|
557
|
+
* Refuses (never returns zero) when the folded status is not `confirmed` — in
|
|
558
|
+
* particular `unknown`/`pending` yield `not_comparable`, never 0. Both sides must
|
|
559
|
+
* already be normalized to one currency at ONE valuation instant: the mixed-basis
|
|
560
|
+
* refusal is delegated to fx-contract's `compareMoney`, so no FX rate is invented.
|
|
561
|
+
*/
|
|
562
|
+
export function deviationOf(projection, basis) {
|
|
563
|
+
const view = foldProjection(projection);
|
|
564
|
+
if (view.status === null) {
|
|
565
|
+
return fail('not_comparable', `projection ${projection.key.projectionKey} has no effective observation — absence of an outcome is not a zero deviation`);
|
|
566
|
+
}
|
|
567
|
+
if (!comparableForDeviation(view.status)) {
|
|
568
|
+
const why = statusTerminality(view.status) === 'open'
|
|
569
|
+
? 'an indeterminate outcome (timeout/unknown/pending) is never a sale and never a zero deviation (#340 acceptance 2)'
|
|
570
|
+
: 'only a confirmed outcome pairs an estimate with a supplier final charge; failed/cancelled/refunded money belongs to the independent aftercare surface (#233)';
|
|
571
|
+
return fail('not_comparable', `folded status ${view.status} is not comparable: ${why}`);
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
compareMoney(basis.estimate, basis.actual);
|
|
575
|
+
}
|
|
576
|
+
catch (e) {
|
|
577
|
+
return fail('mixed_valuation_basis', `estimate and actual must share one normalized currency and one valuation instant: ${e instanceof Error ? e.message : String(e)}`);
|
|
578
|
+
}
|
|
579
|
+
const deltaMinor = basis.actual.amountMinor - basis.estimate.amountMinor;
|
|
580
|
+
return pass(Object.freeze({
|
|
581
|
+
currency: basis.actual.currency,
|
|
582
|
+
deltaMinor,
|
|
583
|
+
estimateMinor: basis.estimate.amountMinor,
|
|
584
|
+
actualMinor: basis.actual.amountMinor,
|
|
585
|
+
direction: deltaMinor > 0n ? 'over' : deltaMinor < 0n ? 'under' : 'exact',
|
|
586
|
+
valuationAsOf: basis.actual.valuationAsOf,
|
|
587
|
+
projectionKey: projection.key.projectionKey,
|
|
588
|
+
basisEntryIds: view.effectiveEntryIds,
|
|
589
|
+
explanation: `confirmed outcome ${formatMoney({ currency: basis.actual.currency, amountMinor: basis.actual.amountMinor })} vs plan estimate ${formatMoney({ currency: basis.estimate.currency, amountMinor: basis.estimate.amountMinor })} at valuation instant ${basis.actual.valuationAsOf}`,
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// 6. Calibration: ranking/future estimates only, never the hard budget
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
/** Declared calibration bounds (ppm; 1_000_000 = neutral ×1.0). Not data-derived. */
|
|
596
|
+
export const CALIBRATION_MODIFIER_PPM_NEUTRAL = 1_000_000;
|
|
597
|
+
export const CALIBRATION_MODIFIER_PPM_MIN = 800_000;
|
|
598
|
+
export const CALIBRATION_MODIFIER_PPM_MAX = 1_250_000;
|
|
599
|
+
/** Minimum comparable sample before any non-neutral modifier is produced. */
|
|
600
|
+
export const CALIBRATION_MIN_SAMPLE = 3;
|
|
601
|
+
/**
|
|
602
|
+
* Build calibration advice from comparable deviations.
|
|
603
|
+
*
|
|
604
|
+
* Indeterminate and non-comparable projections are COUNTED, never valued: they
|
|
605
|
+
* raise `indeterminateCount` and are excluded from the ratio. Below
|
|
606
|
+
* `CALIBRATION_MIN_SAMPLE` the advice is neutral (×1.0) with that stated reason —
|
|
607
|
+
* a thin sample is not evidence. The modifier is always clamped into the declared
|
|
608
|
+
* bounds, and the result is explicitly inadmissible for the hard budget.
|
|
609
|
+
*/
|
|
610
|
+
export function calibrationFromDeviations(inputs) {
|
|
611
|
+
let estimateTotal = 0n;
|
|
612
|
+
let actualTotal = 0n;
|
|
613
|
+
let sampleCount = 0;
|
|
614
|
+
let indeterminateCount = 0;
|
|
615
|
+
let currency = null;
|
|
616
|
+
const basisKeys = [];
|
|
617
|
+
for (const input of inputs) {
|
|
618
|
+
if (input.basis === undefined) {
|
|
619
|
+
indeterminateCount++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const deviation = deviationOf(input.projection, input.basis);
|
|
623
|
+
if (!deviation.ok) {
|
|
624
|
+
if (deviation.code === 'not_comparable') {
|
|
625
|
+
indeterminateCount++;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
return fail(deviation.code, deviation.detail);
|
|
629
|
+
}
|
|
630
|
+
if (currency === null)
|
|
631
|
+
currency = deviation.value.currency;
|
|
632
|
+
else if (currency !== deviation.value.currency) {
|
|
633
|
+
return fail('mixed_valuation_basis', `calibration samples mix currencies (${currency} vs ${deviation.value.currency}); normalize to one currency at one valuation instant first`);
|
|
634
|
+
}
|
|
635
|
+
estimateTotal += deviation.value.estimateMinor;
|
|
636
|
+
actualTotal += deviation.value.actualMinor;
|
|
637
|
+
sampleCount++;
|
|
638
|
+
basisKeys.push(deviation.value.projectionKey);
|
|
639
|
+
}
|
|
640
|
+
const frozen = (modifierPpm, explanation) => Object.freeze({
|
|
641
|
+
schema: OUTCOME_CALIBRATION_SCHEMA,
|
|
642
|
+
appliesTo: 'ranking_and_future_estimate_display',
|
|
643
|
+
modifierPpm,
|
|
644
|
+
sampleCount,
|
|
645
|
+
indeterminateCount,
|
|
646
|
+
explanation,
|
|
647
|
+
basisProjectionKeys: Object.freeze([...basisKeys]),
|
|
648
|
+
admissibleForHardBudget: false,
|
|
649
|
+
});
|
|
650
|
+
if (sampleCount < CALIBRATION_MIN_SAMPLE) {
|
|
651
|
+
return pass(frozen(CALIBRATION_MODIFIER_PPM_NEUTRAL, `neutral ×1.0: ${sampleCount} comparable sample(s) < CALIBRATION_MIN_SAMPLE ${CALIBRATION_MIN_SAMPLE} (${indeterminateCount} indeterminate counted, never valued) — a thin sample is not evidence`));
|
|
652
|
+
}
|
|
653
|
+
if (estimateTotal <= 0n) {
|
|
654
|
+
return pass(frozen(CALIBRATION_MODIFIER_PPM_NEUTRAL, `neutral ×1.0: aggregate estimate basis is ${estimateTotal.toString()} minor units, so no ratio is defined (${indeterminateCount} indeterminate counted)`));
|
|
655
|
+
}
|
|
656
|
+
const rawPpm = Number((actualTotal * 1000000n) / estimateTotal);
|
|
657
|
+
const clamped = Math.min(CALIBRATION_MODIFIER_PPM_MAX, Math.max(CALIBRATION_MODIFIER_PPM_MIN, rawPpm));
|
|
658
|
+
const clampNote = clamped === rawPpm ? '' : ` (raw ${rawPpm} clamped into [${CALIBRATION_MODIFIER_PPM_MIN},${CALIBRATION_MODIFIER_PPM_MAX}])`;
|
|
659
|
+
return pass(frozen(clamped, `actual/estimate = ${actualTotal.toString()}/${estimateTotal.toString()} ${currency ?? ''} over ${sampleCount} confirmed sample(s)${clampNote}; ${indeterminateCount} indeterminate counted separately; applies to ranking and future estimate display only`));
|
|
660
|
+
}
|
|
661
|
+
/** Bounded ranking application: a finite score in, a finite score out. */
|
|
662
|
+
export function applyCalibrationToRankingScore(score, advice) {
|
|
663
|
+
if (!Number.isFinite(score))
|
|
664
|
+
return fail('closed_set', `ranking score must be finite, got ${String(score)}`);
|
|
665
|
+
if (!Number.isInteger(advice?.modifierPpm) || advice.modifierPpm < CALIBRATION_MODIFIER_PPM_MIN || advice.modifierPpm > CALIBRATION_MODIFIER_PPM_MAX) {
|
|
666
|
+
return fail('bound_exceeded', `calibration modifier ${String(advice?.modifierPpm)} is outside the declared bounds [${CALIBRATION_MODIFIER_PPM_MIN},${CALIBRATION_MODIFIER_PPM_MAX}]`);
|
|
667
|
+
}
|
|
668
|
+
if (advice.appliesTo !== 'ranking_and_future_estimate_display') {
|
|
669
|
+
return fail('closed_set', `calibration appliesTo must be 'ranking_and_future_estimate_display', got ${JSON.stringify(advice.appliesTo)}`);
|
|
670
|
+
}
|
|
671
|
+
return pass((score * advice.modifierPpm) / 1_000_000);
|
|
672
|
+
}
|
|
673
|
+
export function calibratedDisplayEstimate(estimate, advice) {
|
|
674
|
+
if (typeof estimate?.amountMinor !== 'bigint')
|
|
675
|
+
return fail('closed_set', 'estimate must be an fx-contract MoneyAmount');
|
|
676
|
+
const applied = applyCalibrationToRankingScore(1, advice);
|
|
677
|
+
if (!applied.ok)
|
|
678
|
+
return applied;
|
|
679
|
+
const scaled = (estimate.amountMinor * BigInt(advice.modifierPpm)) / 1000000n;
|
|
680
|
+
return pass(Object.freeze({
|
|
681
|
+
amountMinor: scaled,
|
|
682
|
+
currency: estimate.currency,
|
|
683
|
+
appliesTo: 'ranking_and_future_estimate_display',
|
|
684
|
+
admissibleForHardBudget: false,
|
|
685
|
+
explanation: `${advice.explanation} — display/ranking only; the hard budget verdict reads the authoritative amount`,
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
/** Sources that may become a budget-admissible total. Calibration is not one. */
|
|
689
|
+
export const AUTHORITATIVE_TOTAL_SOURCES = ['immutable_quote', 'supplier_final'];
|
|
690
|
+
export function authoritativeTotal(amount, source) {
|
|
691
|
+
if (!AUTHORITATIVE_TOTAL_SOURCES.includes(source)) {
|
|
692
|
+
return fail('hard_budget_guard', `a budget-admissible total must come from [${AUTHORITATIVE_TOTAL_SOURCES.join('/')}], got ${JSON.stringify(source)} — a calibrated or modeled amount is never budget-admissible`);
|
|
693
|
+
}
|
|
694
|
+
if (typeof amount?.amountMinor !== 'bigint' || typeof amount?.valuationAsOf !== 'string') {
|
|
695
|
+
return fail('closed_set', 'authoritative total must be an fx-contract NormalizedAmount (currency + bigint minor + valuation instant)');
|
|
696
|
+
}
|
|
697
|
+
return pass(Object.freeze({ authoritativeSource: source, amount }));
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* The user's hard budget verdict. Structurally unreachable from calibration:
|
|
701
|
+
* it admits only an `AuthoritativeTotal`, and a cast-in object that carries no
|
|
702
|
+
* admitted `authoritativeSource` — or that advertises
|
|
703
|
+
* `admissibleForHardBudget: false` — is refused with `hard_budget_guard`.
|
|
704
|
+
* Currency / valuation-instant agreement is delegated to fx-contract.
|
|
705
|
+
*/
|
|
706
|
+
export function hardBudgetVerdict(total, budget) {
|
|
707
|
+
const candidate = total;
|
|
708
|
+
if (candidate?.['admissibleForHardBudget'] === false) {
|
|
709
|
+
return fail('hard_budget_guard', 'a calibrated/display amount advertises admissibleForHardBudget=false and can never override the user hard budget (#340 acceptance 4)');
|
|
710
|
+
}
|
|
711
|
+
if (!AUTHORITATIVE_TOTAL_SOURCES.includes(total?.authoritativeSource)) {
|
|
712
|
+
return fail('hard_budget_guard', `hard budget verdict admits only an AuthoritativeTotal from [${AUTHORITATIVE_TOTAL_SOURCES.join('/')}], got ${JSON.stringify(candidate?.['authoritativeSource'])}`);
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
const cmp = compareMoney(total.amount, budget);
|
|
716
|
+
return pass({ withinBudget: cmp <= 0, valuationAsOf: total.amount.valuationAsOf });
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
return fail('mixed_valuation_basis', `hard budget comparison requires one currency at one valuation instant: ${e instanceof Error ? e.message : String(e)}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/** Runtime source registry, frozen empty. A non-empty value is a founder decision. */
|
|
723
|
+
export const LIVE_SUPPLIER_OUTCOME_SOURCES = [];
|
|
724
|
+
/**
|
|
725
|
+
* The only entry point to a real supplier outcome. While `OUTCOME_TRIGGER_FIRED`
|
|
726
|
+
* is false this refuses BEFORE reading any source, so zero supplier records are
|
|
727
|
+
* consulted (the fetch-spy zero-call proof in run-all §85).
|
|
728
|
+
*/
|
|
729
|
+
export async function ingestSupplierOutcome(query, options) {
|
|
730
|
+
const triggerFired = options?.triggerFired ?? OUTCOME_TRIGGER_FIRED;
|
|
731
|
+
if (!triggerFired) {
|
|
732
|
+
return fail('trigger_deferred', '#340 trigger has not fired (supplier protocol + WriteGate production admission + authorized real order/refund authority facts); zero supplier outcome sources consulted — fixtures, model self-reports and booking attempts do not count as a sale');
|
|
733
|
+
}
|
|
734
|
+
const sources = options?.sources ?? LIVE_SUPPLIER_OUTCOME_SOURCES;
|
|
735
|
+
if (sources.length === 0) {
|
|
736
|
+
return fail('binding_incomplete', 'no supplier outcome source admitted in the registry; admitting one requires an authorization_basis and is a founder decision');
|
|
737
|
+
}
|
|
738
|
+
let last = null;
|
|
739
|
+
for (const source of sources) {
|
|
740
|
+
const out = await source.fetchOutcome(query);
|
|
741
|
+
if (out.ok)
|
|
742
|
+
return pass(out.observation);
|
|
743
|
+
last = fail(out.code === 'permission_denied' ? 'binding_incomplete' : 'not_comparable', `${source.id}: ${out.code} — ${out.detail} (a probe failure is not proof that no order exists)`);
|
|
744
|
+
}
|
|
745
|
+
return last ?? fail('not_comparable', 'no supplier outcome source produced an observation');
|
|
746
|
+
}
|