@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24
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 +102 -194
- package/README.zh-CN.md +115 -210
- package/bin/gotry-backend.js +53 -0
- package/bin/gotry-bootstrap.js +466 -35
- package/bin/gotry-inner.js +255 -36
- package/bin/gotry-process-liveness.d.ts +27 -0
- package/bin/gotry-process-liveness.js +97 -0
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +2 -2
- package/client/client.js +174 -0
- package/cordis.gotry-patch.yml +70 -93
- package/dist/capabilities/agent-reach-deep.js +45 -122
- package/dist/capabilities/agent-reach.js +63 -148
- package/dist/capabilities/anything.js +89 -75
- package/dist/capabilities/artifacts.js +123 -104
- package/dist/capabilities/channel-health.js +65 -45
- package/dist/capabilities/channel-registry.js +108 -87
- package/dist/capabilities/doctor.js +357 -235
- package/dist/capabilities/effect.js +223 -309
- package/dist/capabilities/fact-log.js +28 -14
- package/dist/capabilities/flyai.js +245 -156
- package/dist/capabilities/ground-transfer.js +509 -0
- package/dist/capabilities/hbcli.js +144 -148
- package/dist/capabilities/hotelbyte-transaction.js +194 -0
- package/dist/capabilities/incident-log.js +131 -61
- package/dist/capabilities/model-override.js +30 -10
- package/dist/capabilities/opensky.js +54 -43
- package/dist/capabilities/resilience.js +50 -32
- package/dist/capabilities/session/action-cache.js +50 -44
- package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
- package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
- package/dist/capabilities/session/adapters/dida-portal.js +206 -0
- package/dist/capabilities/session/adapters/meituan-local.js +35 -54
- package/dist/capabilities/session/adapters/rail-12306.js +169 -112
- package/dist/capabilities/session/benchmark.js +85 -149
- package/dist/capabilities/session/extension-bridge.js +320 -259
- package/dist/capabilities/session/extension-channel.js +57 -76
- package/dist/capabilities/session/extension-distribution.js +115 -119
- package/dist/capabilities/session/extract.js +31 -21
- package/dist/capabilities/session/golden-score.js +55 -33
- package/dist/capabilities/session/health-watch.js +146 -154
- package/dist/capabilities/session/read-guard.js +55 -47
- package/dist/capabilities/session/static-flight-golden.js +49 -36
- package/dist/capabilities/session/transport.js +54 -61
- package/dist/capabilities/session/wizard.js +63 -93
- package/dist/capabilities/session-consent.js +143 -47
- package/dist/capabilities/session-login.js +95 -126
- package/dist/capabilities/session-search.js +391 -253
- package/dist/capabilities/sponsor-plugin.js +341 -0
- package/dist/capabilities/visa-policy.js +87 -73
- package/dist/capabilities/weather.js +163 -235
- package/dist/scripts/action-cache-tests.js +24 -59
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
- package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
- package/dist/scripts/agent-reach-deep-tests.js +30 -40
- package/dist/scripts/agent-reach-tests.js +26 -18
- package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
- package/dist/scripts/anything-tests.js +58 -62
- package/dist/scripts/artifact-client-contract-tests.js +69 -0
- package/dist/scripts/artifacts-capability-tests.js +235 -0
- package/dist/scripts/async-collect.js +26 -15
- package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
- package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
- package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
- package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
- package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
- package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
- package/dist/scripts/booking-executor-tests.js +80 -0
- package/dist/scripts/booking-recovery-chain-tests.js +182 -0
- package/dist/scripts/booking-saga-tests.js +61 -123
- package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
- package/dist/scripts/bootstrap-tests.js +1383 -128
- package/dist/scripts/build-changelog.js +89 -130
- package/dist/scripts/build-metrics-report.js +135 -143
- package/dist/scripts/changelog-tests.js +35 -82
- package/dist/scripts/channel-probe-tests.js +44 -116
- package/dist/scripts/channel-probe.js +84 -167
- package/dist/scripts/channel-registry-tests.js +97 -185
- package/dist/scripts/companion-tests.js +25 -70
- package/dist/scripts/dida-cookie-probe.js +87 -0
- package/dist/scripts/dida-sw-probe.js +114 -0
- package/dist/scripts/diff-test.js +17 -12
- package/dist/scripts/doctor-tests.js +428 -120
- package/dist/scripts/dsh-artifact-e2e.js +150 -0
- package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
- package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
- package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
- package/dist/scripts/dsh-runtime-closure.js +75 -63
- package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
- package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
- package/dist/scripts/dsh-target-closure-proof.js +178 -0
- package/dist/scripts/effect-tests.js +165 -552
- package/dist/scripts/engine-run.js +1 -3
- package/dist/scripts/engine-tests.js +16 -10
- package/dist/scripts/evaluation-cadence-tests.js +69 -242
- package/dist/scripts/evaluation-contract-tests.js +193 -499
- package/dist/scripts/extension-distribution-cli.js +19 -15
- package/dist/scripts/extension-distribution-tests.js +98 -211
- package/dist/scripts/extension-tests.js +366 -389
- package/dist/scripts/fact-gate-tests.js +725 -263
- package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
- package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
- package/dist/scripts/flight-malformed-tests.js +159 -0
- package/dist/scripts/flyai-tests.js +325 -68
- package/dist/scripts/g5-guard-tests.js +131 -0
- package/dist/scripts/g5-guard.js +189 -0
- package/dist/scripts/gen-lunar-anchors.js +106 -0
- package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
- package/dist/scripts/ground-transfer-tests.js +956 -0
- package/dist/scripts/hbcli-e2e-tests.js +71 -90
- package/dist/scripts/hbcli-live-optin-tests.js +96 -0
- package/dist/scripts/hbcli-release-contract-tests.js +122 -0
- package/dist/scripts/hbcli-tests.js +41 -115
- package/dist/scripts/health-watch-cli.js +42 -28
- package/dist/scripts/hotel-date-gate-tests.js +496 -0
- package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
- package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
- package/dist/scripts/i18n-tests.js +16 -9
- package/dist/scripts/incident-tests.js +281 -87
- package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
- package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
- package/dist/scripts/issue-271-liveness-tests.js +547 -0
- package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
- package/dist/scripts/issue-289-model-retry-tests.js +441 -0
- package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
- package/dist/scripts/issue-338-home-city-e2e.js +577 -0
- package/dist/scripts/issue343-real-entry-e2e.js +160 -0
- package/dist/scripts/journey-tests.js +22 -27
- package/dist/scripts/kernel-manifest-evidence.js +109 -0
- package/dist/scripts/kernel-manifest-gate.js +126 -0
- package/dist/scripts/kernel-manifest-tests.js +212 -0
- package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
- package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
- package/dist/scripts/kernel-manifest.js +369 -0
- package/dist/scripts/ledger-repair-apply-tests.js +316 -0
- package/dist/scripts/ledger-repair-plan-tests.js +375 -0
- package/dist/scripts/ledger-tests.js +325 -340
- package/dist/scripts/ledger-workflow-crash.js +10 -20
- package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
- package/dist/scripts/memory-capture-tests.js +242 -61
- package/dist/scripts/memory-decay-tests.js +18 -52
- package/dist/scripts/memory-lifecycle-tests.js +657 -0
- package/dist/scripts/memory-lifecycle.js +196 -0
- package/dist/scripts/memory-metrics.js +24 -12
- package/dist/scripts/memory-value-report.js +474 -222
- package/dist/scripts/memory-value-tests.js +328 -0
- package/dist/scripts/metrics-report-tests.js +62 -199
- package/dist/scripts/model-override-e2e.js +66 -105
- package/dist/scripts/nightly-evidence-tests.js +64 -89
- package/dist/scripts/nightly-evidence.js +108 -111
- package/dist/scripts/nudge-digest.js +39 -28
- package/dist/scripts/onboarding-tests.js +63 -88
- package/dist/scripts/opensky-check.js +18 -13
- package/dist/scripts/opensky-tests.js +28 -28
- package/dist/scripts/persona-surface-guard-tests.js +37 -12
- package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
- package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
- package/dist/scripts/price-drift-tests.js +126 -227
- package/dist/scripts/price-drift-watch.js +209 -253
- package/dist/scripts/probe-poi-tests.js +52 -45
- package/dist/scripts/product-metrics.js +187 -273
- package/dist/scripts/publish-preverify.js +47 -41
- package/dist/scripts/realtime-pricing-tests.js +37 -76
- package/dist/scripts/replay-async.js +20 -18
- package/dist/scripts/replay-real.js +16 -18
- package/dist/scripts/replay.js +52 -82
- package/dist/scripts/session-attach-diagnose.js +16 -12
- package/dist/scripts/session-attach-poc.js +34 -37
- package/dist/scripts/session-benchmark.js +66 -147
- package/dist/scripts/session-dida-live-e2e.js +151 -0
- package/dist/scripts/session-extract-tests.js +14 -35
- package/dist/scripts/session-login.js +14 -13
- package/dist/scripts/session-tests.js +545 -583
- package/dist/scripts/sf-live-benchmark.js +92 -110
- package/dist/scripts/sf-live-cli-tests.js +2 -9
- package/dist/scripts/sf-soft-score-tests.js +19 -60
- package/dist/scripts/sf-summary-tests.js +149 -0
- package/dist/scripts/sf-summary.js +466 -84
- package/dist/scripts/skeleton-check.js +16 -14
- package/dist/scripts/skeleton-integration-test.js +11 -10
- package/dist/scripts/skills-contract-tests.js +51 -46
- package/dist/scripts/smoke-session-gate-tests.js +12 -20
- package/dist/scripts/smoke.js +541 -422
- package/dist/scripts/sponsor-reuse-tests.js +262 -0
- package/dist/scripts/state-cli-tests.js +226 -101
- package/dist/scripts/state-cli.js +549 -212
- package/dist/scripts/static-golden-tests.js +46 -180
- package/dist/scripts/time-eval-tests.js +210 -192
- package/dist/scripts/travel-timeline-tests.js +29 -93
- package/dist/scripts/turn-handoff-collect-tests.js +46 -93
- package/dist/scripts/turn-handoff-collect.js +71 -99
- package/dist/scripts/turn-policy-tests.js +28 -29
- package/dist/scripts/typed-contract-canary.js +77 -162
- package/dist/scripts/tz-resolver-sanity.js +50 -0
- package/dist/scripts/tz-resolver-tests.js +584 -0
- package/dist/scripts/unified-tests.js +23 -19
- package/dist/scripts/visa-policy-tests.js +31 -44
- package/dist/scripts/weather-tests.js +121 -543
- package/dist/scripts/wish-channel-gate-tests.js +29 -86
- package/dist/scripts/write-gate-tests.js +522 -0
- package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
- package/dist/scripts/z3-lifecycle-tests.js +170 -0
- package/dist/scripts/z3-race-repeat-tests.js +39 -0
- package/dist/scripts/z3-race-tests.js +18 -12
- package/dist/src/artifact-gate.js +652 -221
- package/dist/src/backend/kernel.js +91 -0
- package/dist/src/backend/modules/booking-copilot.js +55 -0
- package/dist/src/backend/modules/booking-executor.js +124 -0
- package/dist/src/backend/modules/session-search.js +194 -0
- package/dist/src/benchmark-agent-conformance.js +296 -262
- package/dist/src/benchmark-environment-bridge.js +298 -413
- package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
- package/dist/src/benchmark-tool-isolation.js +94 -67
- package/dist/src/bookable-facts.js +299 -174
- package/dist/src/booking-saga.js +64 -120
- package/dist/src/booking-surface/availability-policy.js +443 -576
- package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
- package/dist/src/booking-surface/contracts.js +24 -80
- package/dist/src/booking-surface/dsh-planner.js +510 -281
- package/dist/src/booking-surface/dsh-plugin.js +17 -1
- package/dist/src/booking-surface/error-codes.js +82 -10
- package/dist/src/booking-surface/index.js +0 -3
- package/dist/src/booking-surface/profile.js +10 -54
- package/dist/src/booking-surface/recovery-chain.js +367 -0
- package/dist/src/booking-surface/runtime.js +765 -891
- package/dist/src/booking-surface/server.js +200 -251
- package/dist/src/booking-surface/startup.js +58 -54
- package/dist/src/booking-surface/validation.js +150 -218
- package/dist/src/bridge.js +20 -13
- package/dist/src/companions.js +48 -74
- package/dist/src/contracts.js +21 -43
- package/dist/src/dsh-llm.js +82 -120
- package/dist/src/engine.js +253 -239
- package/dist/src/evaluation-cadence.js +72 -209
- package/dist/src/evaluation-contracts.js +220 -843
- package/dist/src/flight-pack-adapter.js +54 -0
- package/dist/src/flight-pack-contract.js +2 -0
- package/dist/src/gotry-backend.js +70 -0
- package/dist/src/hotel-date-gate.js +118 -0
- package/dist/src/i18n.js +18 -7
- package/dist/src/index.js +1425 -1629
- package/dist/src/journey.js +132 -81
- package/dist/src/ledger-repair-apply.js +538 -0
- package/dist/src/ledger-repair-plan.js +463 -0
- package/dist/src/loop.js +257 -166
- package/dist/src/memory-capture.js +180 -32
- package/dist/src/memory-decay.js +30 -14
- package/dist/src/memory-lifecycle.js +1310 -0
- package/dist/src/memory-utility.js +27 -40
- package/dist/src/mock-llm.js +36 -61
- package/dist/src/model.js +81 -36
- package/dist/src/realtime-pricing.js +63 -72
- package/dist/src/slot-spec.js +84 -106
- package/dist/src/state-ledger.js +310 -471
- package/dist/src/subagent-job-id-guard.js +58 -0
- package/dist/src/time-anchor.js +231 -33
- package/dist/src/tool-packet.js +20 -8
- package/dist/src/travel-slots.js +58 -35
- package/dist/src/travel-timeline.js +44 -63
- package/dist/src/turn-deadline.js +121 -127
- package/dist/src/turn-policy.js +61 -89
- package/dist/src/tz-resolver.js +143 -0
- package/dist/src/unified.js +504 -381
- package/dist/src/wish-pool.js +39 -14
- package/dist/src/write-gate.js +495 -0
- package/dist/src/z3-shared.js +273 -7
- package/extension/README.md +4 -3
- package/extension/background.js +125 -14
- package/extension/content-bridge.js +23 -1
- package/extension/content-main.js +15 -4
- package/extension/manifest.json +16 -8
- package/package.json +251 -220
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/anything.ts +1 -1
- package/ts/capabilities/artifacts.ts +46 -9
- package/ts/capabilities/flyai.ts +156 -37
- package/ts/capabilities/hbcli.ts +1 -1
- package/ts/capabilities/incident-log.ts +88 -59
- package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
- package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
- package/ts/capabilities/session/extension-bridge.ts +226 -101
- package/ts/capabilities/session/extension-channel.ts +22 -9
- package/ts/capabilities/session/wizard.ts +2 -2
- package/ts/capabilities/session-consent.ts +98 -14
- package/ts/capabilities/session-login.ts +1 -0
- package/ts/capabilities/session-search.ts +315 -31
- package/ts/dsh-runtime/vendor/README.md +8 -4
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
- package/ts/package.json +236 -16
- package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
- package/ts/scripts/state-cli.ts +418 -51
- package/ts/src/artifact-gate.ts +561 -15
- package/ts/src/benchmark-environment-bridge.ts +37 -20
- package/ts/src/bookable-facts.ts +183 -7
- package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
- package/ts/src/booking-surface/dsh-planner.ts +240 -32
- package/ts/src/booking-surface/dsh-plugin.js +17 -1
- package/ts/src/booking-surface/error-codes.ts +75 -0
- package/ts/src/booking-surface/recovery-chain.ts +409 -0
- package/ts/src/booking-surface/server.ts +84 -51
- package/ts/src/booking-surface/validation.ts +2 -0
- package/ts/src/contracts.ts +12 -2
- package/ts/src/dsh-llm.ts +4 -8
- package/ts/src/index.ts +609 -90
- package/ts/src/loop.ts +116 -7
- package/ts/src/memory-capture.ts +227 -3
- package/ts/src/mock-llm.ts +4 -8
- package/ts/src/model.ts +88 -2
- package/ts/src/state-ledger.ts +47 -29
- package/ts/src/subagent-job-id-guard.ts +72 -0
- package/ts/src/time-anchor.ts +217 -13
- package/ts/src/unified.ts +316 -54
package/dist/src/wish-pool.js
CHANGED
|
@@ -1,32 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 愿望池匹配纯函数(M4「下一次出发」召回的判定层):条件评分 + 0..1 挑选。
|
|
3
|
+
* 判定归代码(契约 4),模型与脚本都只消费结果。
|
|
4
|
+
*
|
|
5
|
+
* 纪律:0..1(一轮至多一条建议)/muted 永不召回/无命中返回 null(不硬推);
|
|
6
|
+
* tie-break 用 added_at 最早优先(先许的愿先兑现),同刻则 wish_id 字典序——确定性,可重放。
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* 条件评分:days/budget 为「窗口 ≥ 条件」,month 为「命中 best_months」;条件缺失的项不计分。
|
|
10
|
+
* 通道否证(接缝第 2 段):conditions.channels 命名依赖通道,任一处于 down 且
|
|
11
|
+
* ctx.channelDown 已提供 → 否证成行条件,返回 null(当下推这个愿望=体验差);
|
|
12
|
+
* 通道全健康或健康面缺席(未传 channelDown)不加分不否——「佐证」渲染面留后续切片。
|
|
13
|
+
*/
|
|
1
14
|
export function scoreWishMatch(entry, ctx) {
|
|
2
|
-
const c = entry.conditions ?? {};
|
|
15
|
+
const c = (entry.conditions ?? {});
|
|
3
16
|
if (ctx.channelDown && Array.isArray(c.channels)) {
|
|
4
|
-
for (const ch of c.channels){
|
|
5
|
-
if (typeof ch === 'string' && ctx.channelDown.has(ch))
|
|
17
|
+
for (const ch of c.channels) {
|
|
18
|
+
if (typeof ch === 'string' && ctx.channelDown.has(ch))
|
|
19
|
+
return null;
|
|
6
20
|
}
|
|
7
21
|
}
|
|
8
22
|
const hits = [];
|
|
9
|
-
if (typeof ctx.days === 'number' && typeof c.days === 'number' && ctx.days >= c.days)
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
23
|
+
if (typeof ctx.days === 'number' && typeof c.days === 'number' && ctx.days >= c.days)
|
|
24
|
+
hits.push(`days≥${c.days}`);
|
|
25
|
+
if (typeof ctx.budgetCny === 'number' && typeof c.budget_cny === 'number' && ctx.budgetCny >= c.budget_cny)
|
|
26
|
+
hits.push(`budget≥${c.budget_cny}`);
|
|
27
|
+
if (Array.isArray(c.best_months) && typeof ctx.month === 'number' && c.best_months.map(Number).includes(ctx.month))
|
|
28
|
+
hits.push(`month=${ctx.month}`);
|
|
29
|
+
if (hits.length === 0)
|
|
30
|
+
return null;
|
|
13
31
|
return {
|
|
14
32
|
entry,
|
|
15
33
|
wishId: String(entry.wish_id ?? ''),
|
|
16
34
|
score: hits.length,
|
|
17
|
-
hits
|
|
35
|
+
hits,
|
|
18
36
|
};
|
|
19
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 0..1 挑选:muted 永不召回;无稳定 wish_id 的条目不召回(sidecar 依赖主键);
|
|
40
|
+
* 取最高分,tie-break added_at 最早 → wish_id 字典序。无命中返回 null。
|
|
41
|
+
*/
|
|
20
42
|
export function pickNudgeWish(pool, ctx) {
|
|
21
|
-
const scored = pool
|
|
22
|
-
|
|
43
|
+
const scored = pool
|
|
44
|
+
.filter(e => !e.muted && typeof e.wish_id === 'string')
|
|
45
|
+
.map(e => scoreWishMatch(e, ctx))
|
|
46
|
+
.filter((m) => m !== null)
|
|
47
|
+
.sort((a, b) => {
|
|
48
|
+
if (b.score !== a.score)
|
|
49
|
+
return b.score - a.score;
|
|
23
50
|
const ta = String(a.entry.added_at ?? '');
|
|
24
51
|
const tb = String(b.entry.added_at ?? '');
|
|
25
|
-
if (ta !== tb)
|
|
52
|
+
if (ta !== tb)
|
|
53
|
+
return ta < tb ? -1 : 1;
|
|
26
54
|
return a.wishId < b.wishId ? -1 : 1;
|
|
27
55
|
});
|
|
28
56
|
return scored[0] ?? null;
|
|
29
57
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//# sourceURL=ts/src/wish-pool.ts
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WriteGate 机制层(write_gate.v1)——issue #231「持久化可信审批 + 原子 outbox」。
|
|
3
|
+
*
|
|
4
|
+
* 立场(docs/design/write-gate-production-design.md §3/§5 + docs/design/fact-writegate-seam.md §2.2):
|
|
5
|
+
* 本模块只实现「审批与 outbox 的持久化机制与契约」——
|
|
6
|
+
* - PreparedChallenge:呈现前由服务端备妥的一次性挑战(nonce/fingerprint/披露在呈现前冻结,
|
|
7
|
+
* 只持久化 nonce 摘要,原始 nonce 只经可信宿主呈现通道交付,永不入账本);
|
|
8
|
+
* - ApprovalReceipt / ApprovalClaim:只有可信宿主真人确认回调可发行的 L3 一次性消费凭据,
|
|
9
|
+
* 绑定 actor/tenant/traveler/seam/challenge/fingerprint;prepared 不构成授权;
|
|
10
|
+
* - WriteEffectIntent:durable outbox 意图(本地 exactly-once 记账面,不承诺外部 exactly-once)。
|
|
11
|
+
* 两段事务边界严格分离、不得合并(write-gate-production-design §2.2):
|
|
12
|
+
* 事务1 = receipt 一次性消费 + pending_writes pending→confirmed + outbox 入队(单事务原子);
|
|
13
|
+
* 事务2 = dispatcher 原子领取(queued 条件 UPDATE,affected rows=1 唯一赢家)+ attempt/fencing
|
|
14
|
+
* 先于任何外部调用落库(独立事务,attempt_id 落库即不可变)。
|
|
15
|
+
*
|
|
16
|
+
* 存储层物理红线(数据库物理 CHECK/外键/触发器 = 「等价不可绕过约束」,非应用层 if):
|
|
17
|
+
* - write_effect_intents.receipt_id 非空 CHECK + 复合外键 (tenant_id, receipt_id) → approval_claims
|
|
18
|
+
* ⇒ 空 receipt / 跨租户 receipt 的 outbox 行在物理上不可能(D-22/#306 锚点的 outbox 侧面;
|
|
19
|
+
* pending_writes 本表的空 receipt 物理 CHECK 仍属 M5 Entry 拍板项,本模块不改动 ADR-17 三态);
|
|
20
|
+
* - dispatch_status 只进不退触发器:dispatching/dispatched/rejected 永不回 queued——
|
|
21
|
+
* 崩溃/租约过期一律 unknown/query/manual reconcile,不得重置为未执行再下单;
|
|
22
|
+
* - attempt_id 落库即不可变触发器(对账只认同一 attempt,不得另起);
|
|
23
|
+
* - dispatching/dispatched 必携完整领取字段(attempt_id/fencing_token/claimed_by/lease_until)CHECK
|
|
24
|
+
* ⇒ 「调用先于领取落库」在存储层无法成立。
|
|
25
|
+
*
|
|
26
|
+
* 激活闸(M5 Entry 前,门 = issue #136):本模块零真实供应商/交易调用——API 止于 outbox 入队、
|
|
27
|
+
* 领取、派发前复验与本地 rejected/unknown 记账;真实派发调用方属于 #232(M5 Entry 后)。
|
|
28
|
+
* 产品运行时在 M5 Entry 前不实例化 WriteGate:表结构由 ensureWriteGate 惰性创建,
|
|
29
|
+
* 默认路径(工具注册面/账本 SCHEMA/state-ledger 行为)零变化——非运行时激活。
|
|
30
|
+
* 模型授权边界:模型只能请求呈现(prepare 走 L2 requestPendingWrite + 备妥挑战);
|
|
31
|
+
* 确认消费通道只认可信宿主回调携带的原始 delivery_nonce(账本只有其摘要)——
|
|
32
|
+
* 即使携带合法 actor 与合法快照,仅凭摘要/伪造 nonce 的确认一律拒绝且无 outbox。
|
|
33
|
+
*/
|
|
34
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
35
|
+
export const WRITE_GATE_SCHEMA = 'write_gate.v1';
|
|
36
|
+
/** 请求指纹 schema(设计 §4;canonical JSON + SHA-256,呈现卡/审批凭据/供应商写效果三处同源) */
|
|
37
|
+
export const WRITE_REQUEST_FINGERPRINT_SCHEMA = 'gotry_write_request_fingerprint.v1';
|
|
38
|
+
/**
|
|
39
|
+
* 具名 seam 词汇(封闭集,失败关闭)。冻结本身是 M5 Entry 拍板项(#136/#306);
|
|
40
|
+
* 当前仅收录设计文档已具名的 seam 字面量,Entry 前不新增。
|
|
41
|
+
*/
|
|
42
|
+
export const WRITE_GATE_SEAMS = ['hotelbyte-hotel-book-confirm'];
|
|
43
|
+
/** outbox 派发状态字母表(设计 §3;rejected 是 outbox/派发层终态,不扩展 ADR-17 pending_writes 三态) */
|
|
44
|
+
export const DISPATCH_STATUSES = ['queued', 'dispatching', 'dispatched', 'rejected'];
|
|
45
|
+
/** PreparedChallenge 状态字母表(设计 §3;expired 可由 sweep 或被新挑战取代时落库) */
|
|
46
|
+
export const CHALLENGE_STATUSES = ['prepared', 'confirmed', 'consumed', 'expired'];
|
|
47
|
+
function sha256(s) {
|
|
48
|
+
return createHash('sha256').update(s, 'utf8').digest('hex');
|
|
49
|
+
}
|
|
50
|
+
/** 服务端生成的一次性 nonce(32 字节随机;只有摘要入账本,原始值只经可信宿主通道交付) */
|
|
51
|
+
export function makeDeliveryNonce() {
|
|
52
|
+
return randomBytes(32).toString('hex');
|
|
53
|
+
}
|
|
54
|
+
/** canonical JSON:键递归排序,序列化稳定(同内容任意键序同摘要;JS 值域内确定) */
|
|
55
|
+
export function canonicalJson(v) {
|
|
56
|
+
if (Array.isArray(v))
|
|
57
|
+
return '[' + v.map(canonicalJson).join(',') + ']';
|
|
58
|
+
if (v !== null && typeof v === 'object') {
|
|
59
|
+
const obj = v;
|
|
60
|
+
return '{' + Object.keys(obj).sort().map(k => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
|
|
61
|
+
}
|
|
62
|
+
return JSON.stringify(v) ?? 'null';
|
|
63
|
+
}
|
|
64
|
+
/** 计算请求指纹(canonical 字段 + schema + 该次呈现绑定的 nonce 摘要;呈现前冻结,确认后不可变) */
|
|
65
|
+
export function computeRequestFingerprint(fields, deliveryNonceDigest) {
|
|
66
|
+
return sha256(canonicalJson({
|
|
67
|
+
schema: WRITE_REQUEST_FINGERPRINT_SCHEMA,
|
|
68
|
+
tenant_id: fields.tenantId,
|
|
69
|
+
actor_ref: fields.actorRef,
|
|
70
|
+
principal_ref: fields.principalRef,
|
|
71
|
+
...(fields.sponsorRef !== undefined ? { sponsor_ref: fields.sponsorRef } : {}),
|
|
72
|
+
seam: fields.seam,
|
|
73
|
+
supplier: fields.supplier,
|
|
74
|
+
product_ref: fields.productRef,
|
|
75
|
+
travel_terms: fields.travelTerms,
|
|
76
|
+
amount_total: fields.amountTotal,
|
|
77
|
+
currency: fields.currency,
|
|
78
|
+
commission_disclosure: fields.commissionDisclosure,
|
|
79
|
+
valid_until: fields.validUntil,
|
|
80
|
+
presentation_key: fields.presentationKey,
|
|
81
|
+
delivery_nonce_digest: deliveryNonceDigest,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
// ---- 存储面:惰性 schema(只有实例化 WriteGate 的进程才创建这些表) --------------------
|
|
85
|
+
export const WRITE_GATE_SCHEMA_SQL = `
|
|
86
|
+
CREATE TABLE IF NOT EXISTS prepared_challenges (
|
|
87
|
+
tenant_id TEXT NOT NULL DEFAULT 'local',
|
|
88
|
+
challenge_id TEXT NOT NULL,
|
|
89
|
+
idem_key TEXT NOT NULL,
|
|
90
|
+
request_fingerprint_sha256 TEXT NOT NULL CHECK (length(request_fingerprint_sha256) = 64),
|
|
91
|
+
presentation_key TEXT NOT NULL CHECK (length(presentation_key) > 0),
|
|
92
|
+
delivery_nonce_digest TEXT NOT NULL CHECK (length(delivery_nonce_digest) = 64),
|
|
93
|
+
actor_ref TEXT NOT NULL CHECK (length(actor_ref) > 0),
|
|
94
|
+
principal_ref TEXT NOT NULL CHECK (length(principal_ref) > 0),
|
|
95
|
+
seam TEXT NOT NULL CHECK (length(seam) > 0),
|
|
96
|
+
amount_total INTEGER NOT NULL CHECK (amount_total >= 0),
|
|
97
|
+
currency TEXT NOT NULL CHECK (length(currency) > 0),
|
|
98
|
+
valid_until TEXT NOT NULL,
|
|
99
|
+
prepared_at TEXT NOT NULL,
|
|
100
|
+
expires_at TEXT NOT NULL,
|
|
101
|
+
status TEXT NOT NULL DEFAULT 'prepared' CHECK (status IN ('prepared','confirmed','consumed','expired')),
|
|
102
|
+
PRIMARY KEY (tenant_id, challenge_id)
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS prepared_challenges_idem ON prepared_challenges(tenant_id, idem_key);
|
|
105
|
+
CREATE UNIQUE INDEX IF NOT EXISTS prepared_challenges_nonce ON prepared_challenges(tenant_id, delivery_nonce_digest);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS approval_claims (
|
|
108
|
+
tenant_id TEXT NOT NULL DEFAULT 'local',
|
|
109
|
+
receipt_id TEXT NOT NULL CHECK (length(receipt_id) > 0),
|
|
110
|
+
challenge_id TEXT NOT NULL,
|
|
111
|
+
idem_key TEXT NOT NULL,
|
|
112
|
+
nonce_digest TEXT NOT NULL CHECK (length(nonce_digest) = 64),
|
|
113
|
+
request_fingerprint_sha256 TEXT NOT NULL CHECK (length(request_fingerprint_sha256) = 64),
|
|
114
|
+
actor_ref TEXT NOT NULL CHECK (length(actor_ref) > 0),
|
|
115
|
+
principal_ref TEXT NOT NULL CHECK (length(principal_ref) > 0),
|
|
116
|
+
seam TEXT NOT NULL CHECK (length(seam) > 0),
|
|
117
|
+
presentation_key TEXT NOT NULL CHECK (length(presentation_key) > 0),
|
|
118
|
+
valid_until TEXT NOT NULL,
|
|
119
|
+
issued_at TEXT NOT NULL,
|
|
120
|
+
consumed_at TEXT,
|
|
121
|
+
amount_total INTEGER NOT NULL CHECK (amount_total >= 0),
|
|
122
|
+
currency TEXT NOT NULL CHECK (length(currency) > 0),
|
|
123
|
+
PRIMARY KEY (tenant_id, receipt_id),
|
|
124
|
+
UNIQUE (tenant_id, challenge_id),
|
|
125
|
+
UNIQUE (tenant_id, nonce_digest)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE IF NOT EXISTS write_effect_intents (
|
|
129
|
+
tenant_id TEXT NOT NULL DEFAULT 'local',
|
|
130
|
+
idem_key TEXT NOT NULL,
|
|
131
|
+
effect_name TEXT NOT NULL CHECK (length(effect_name) > 0),
|
|
132
|
+
receipt_id TEXT NOT NULL CHECK (length(receipt_id) > 0),
|
|
133
|
+
supplier_attempt_key TEXT NOT NULL CHECK (length(supplier_attempt_key) > 0),
|
|
134
|
+
request_fingerprint_sha256 TEXT NOT NULL CHECK (length(request_fingerprint_sha256) = 64),
|
|
135
|
+
nonce_digest TEXT NOT NULL CHECK (length(nonce_digest) = 64),
|
|
136
|
+
actor_ref TEXT NOT NULL CHECK (length(actor_ref) > 0),
|
|
137
|
+
principal_ref TEXT NOT NULL CHECK (length(principal_ref) > 0),
|
|
138
|
+
seam TEXT NOT NULL CHECK (length(seam) > 0),
|
|
139
|
+
attempt_budget INTEGER NOT NULL CHECK (attempt_budget > 0),
|
|
140
|
+
next_action TEXT NOT NULL DEFAULT 'dispatch' CHECK (next_action IN ('dispatch','query','manual-reconcile')),
|
|
141
|
+
dispatch_status TEXT NOT NULL DEFAULT 'queued' CHECK (dispatch_status IN ('queued','dispatching','dispatched','rejected')),
|
|
142
|
+
attempt_id TEXT,
|
|
143
|
+
fencing_token INTEGER,
|
|
144
|
+
claimed_by TEXT,
|
|
145
|
+
lease_until TEXT,
|
|
146
|
+
reject_reason TEXT,
|
|
147
|
+
created TEXT NOT NULL,
|
|
148
|
+
updated TEXT NOT NULL,
|
|
149
|
+
PRIMARY KEY (tenant_id, idem_key),
|
|
150
|
+
UNIQUE (tenant_id, supplier_attempt_key),
|
|
151
|
+
FOREIGN KEY (tenant_id, receipt_id) REFERENCES approval_claims(tenant_id, receipt_id),
|
|
152
|
+
CHECK (dispatch_status != 'dispatching' OR (attempt_id IS NOT NULL AND fencing_token IS NOT NULL AND claimed_by IS NOT NULL AND lease_until IS NOT NULL)),
|
|
153
|
+
CHECK (dispatch_status != 'queued' OR (attempt_id IS NULL AND fencing_token IS NULL AND claimed_by IS NULL AND lease_until IS NULL)),
|
|
154
|
+
CHECK (dispatch_status != 'dispatched' OR (attempt_id IS NOT NULL AND fencing_token IS NOT NULL AND claimed_by IS NOT NULL)),
|
|
155
|
+
CHECK (dispatch_status != 'rejected' OR reject_reason IS NOT NULL AND length(reject_reason) > 0)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
CREATE TRIGGER IF NOT EXISTS write_effect_intents_attempt_immutable
|
|
159
|
+
BEFORE UPDATE OF attempt_id ON write_effect_intents
|
|
160
|
+
WHEN OLD.attempt_id IS NOT NULL AND NEW.attempt_id IS NOT OLD.attempt_id
|
|
161
|
+
BEGIN
|
|
162
|
+
SELECT RAISE(ABORT, 'write_effect_intents.attempt_id is immutable once persisted');
|
|
163
|
+
END;
|
|
164
|
+
|
|
165
|
+
CREATE TRIGGER IF NOT EXISTS write_effect_intents_dispatch_forward_only
|
|
166
|
+
BEFORE UPDATE OF dispatch_status ON write_effect_intents
|
|
167
|
+
WHEN (OLD.dispatch_status = 'dispatching' AND NEW.dispatch_status = 'queued')
|
|
168
|
+
OR (OLD.dispatch_status IN ('dispatched','rejected') AND NEW.dispatch_status IN ('queued','dispatching'))
|
|
169
|
+
BEGIN
|
|
170
|
+
SELECT RAISE(ABORT, 'write_effect_intents.dispatch_status is forward-only: dispatching/dispatched/rejected can never return to queued');
|
|
171
|
+
END;
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS write_gate_revocations (
|
|
174
|
+
tenant_id TEXT NOT NULL DEFAULT 'local',
|
|
175
|
+
actor_ref TEXT NOT NULL DEFAULT '*',
|
|
176
|
+
principal_ref TEXT NOT NULL DEFAULT '*',
|
|
177
|
+
seam TEXT NOT NULL DEFAULT '*',
|
|
178
|
+
reason TEXT NOT NULL CHECK (length(reason) > 0),
|
|
179
|
+
revoked_at TEXT NOT NULL,
|
|
180
|
+
PRIMARY KEY (tenant_id, actor_ref, principal_ref, seam)
|
|
181
|
+
);
|
|
182
|
+
`;
|
|
183
|
+
/** 幂等建表 + 开启本连接外键强制(FK 是跨租户 receipt 的物理闸;既有表无外键,零行为影响) */
|
|
184
|
+
export function ensureWriteGate(db) {
|
|
185
|
+
db.pragma('foreign_keys = ON');
|
|
186
|
+
db.exec(WRITE_GATE_SCHEMA_SQL);
|
|
187
|
+
}
|
|
188
|
+
/** 事务内拒绝哨兵:突变点之后的拒绝必须 throw(回滚),不得 return(better-sqlite3 事务会提交 return) */
|
|
189
|
+
class WriteGateRejectionError extends Error {
|
|
190
|
+
reason;
|
|
191
|
+
constructor(reason) {
|
|
192
|
+
super(reason);
|
|
193
|
+
this.reason = reason;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function nowIso(now) {
|
|
197
|
+
return now ?? new Date().toISOString();
|
|
198
|
+
}
|
|
199
|
+
export class WriteGate {
|
|
200
|
+
ledger;
|
|
201
|
+
constructor(ledger) {
|
|
202
|
+
ensureWriteGate(ledger.db);
|
|
203
|
+
this.ledger = ledger;
|
|
204
|
+
}
|
|
205
|
+
get db() { return this.ledger.db; }
|
|
206
|
+
get tenant() { return this.ledger.tenant; }
|
|
207
|
+
// ---- L2/呈现前备妥(模型可请求展示的唯一通道) ---------------------------------
|
|
208
|
+
/**
|
|
209
|
+
* 呈现前服务端备妥:冻结 nonce/fingerprint/披露并绑定不可变请求。
|
|
210
|
+
* 只在 pending_writes 存在且为 pending 时可备妥;重复备妥 = 旧挑战立即失效
|
|
211
|
+
* (stale challenge 不得在重新呈现后确认)。prepared 不构成授权。
|
|
212
|
+
*/
|
|
213
|
+
preparePresentation(input) {
|
|
214
|
+
const at = nowIso(input.now);
|
|
215
|
+
if (!WRITE_GATE_SEAMS.includes(input.fields.seam))
|
|
216
|
+
return { ok: false, reason: 'unknown-seam' };
|
|
217
|
+
const run = this.db.transaction(() => {
|
|
218
|
+
const pending = this.db.prepare('SELECT status FROM pending_writes WHERE tenant_id = ? AND idem_key = ?').get(this.tenant, input.idemKey);
|
|
219
|
+
if (!pending)
|
|
220
|
+
return { ok: false, reason: 'missing-subject' };
|
|
221
|
+
if (pending.status !== 'pending')
|
|
222
|
+
return { ok: false, reason: 'subject-not-pending' };
|
|
223
|
+
// 重新呈现:同 idem 的旧 prepared 挑战立即失效(stale nonce/旧卡片不得再确认)
|
|
224
|
+
this.db.prepare(`UPDATE prepared_challenges SET status = 'expired' WHERE tenant_id = ? AND idem_key = ? AND status = 'prepared'`).run(this.tenant, input.idemKey);
|
|
225
|
+
const deliveryNonce = makeDeliveryNonce();
|
|
226
|
+
const nonceDigest = sha256(deliveryNonce);
|
|
227
|
+
const fingerprint = computeRequestFingerprint(input.fields, nonceDigest);
|
|
228
|
+
const challengeId = 'ch-' + randomUUID();
|
|
229
|
+
this.db.prepare(`INSERT INTO prepared_challenges (tenant_id, challenge_id, idem_key, request_fingerprint_sha256, presentation_key, delivery_nonce_digest, actor_ref, principal_ref, seam, amount_total, currency, valid_until, prepared_at, expires_at, status)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'prepared')`).run(this.tenant, challengeId, input.idemKey, fingerprint, input.fields.presentationKey, nonceDigest, input.fields.actorRef, input.fields.principalRef, input.fields.seam, input.fields.amountTotal, input.fields.currency, input.fields.validUntil, at, input.expiresAt);
|
|
231
|
+
this.ledger.insertEvent({
|
|
232
|
+
actor: 'system:writegate',
|
|
233
|
+
kind: 'writegate.challenge_prepared',
|
|
234
|
+
subjectId: input.idemKey,
|
|
235
|
+
payload: { challenge_id: challengeId, request_fingerprint_sha256: fingerprint, delivery_nonce_digest: nonceDigest },
|
|
236
|
+
idemKey: `wgc:${challengeId}`,
|
|
237
|
+
ts: at,
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
card: {
|
|
242
|
+
schema: WRITE_GATE_SCHEMA,
|
|
243
|
+
challenge_id: challengeId,
|
|
244
|
+
idem_key: input.idemKey,
|
|
245
|
+
seam: input.fields.seam,
|
|
246
|
+
request_fingerprint_sha256: fingerprint,
|
|
247
|
+
delivery_nonce_digest: nonceDigest,
|
|
248
|
+
amount_total: input.fields.amountTotal,
|
|
249
|
+
currency: input.fields.currency,
|
|
250
|
+
travel_terms: input.fields.travelTerms,
|
|
251
|
+
commission_disclosure: input.fields.commissionDisclosure,
|
|
252
|
+
valid_until: input.fields.validUntil,
|
|
253
|
+
presentation_key: input.fields.presentationKey,
|
|
254
|
+
prepared_at: at,
|
|
255
|
+
expires_at: input.expiresAt,
|
|
256
|
+
},
|
|
257
|
+
trustedDelivery: { deliveryNonce },
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
return run();
|
|
261
|
+
}
|
|
262
|
+
/** 过期清理(sweep):prepared 且 expires_at 已过 → expired;过期挑战不得确认/消费 */
|
|
263
|
+
sweepExpiredChallenges(now) {
|
|
264
|
+
const info = this.db.prepare(`UPDATE prepared_challenges SET status = 'expired' WHERE tenant_id = ? AND status = 'prepared' AND expires_at < ?`).run(this.tenant, nowIso(now));
|
|
265
|
+
return info.changes;
|
|
266
|
+
}
|
|
267
|
+
// ---- 可信宿主确认回调:发行 receipt(事务:挑战 confirmed + approval_claims 发行记录) ----
|
|
268
|
+
/**
|
|
269
|
+
* L3 可信宿主真人确认回调 → 服务端发行 ApprovalReceipt( issuance 记录,consumed_at 仍为 NULL)。
|
|
270
|
+
* 校验:挑战存在/仍为 prepared/未过期、原始 delivery_nonce 摘要匹配(模型只有摘要 → 冒充必拒)、
|
|
271
|
+
* actor/seam 绑定一致、无 L4 撤回。模型工具调用不在本通道(字段匹配 ≠ 用户许可)。
|
|
272
|
+
*/
|
|
273
|
+
trustedHostConfirm(input) {
|
|
274
|
+
const at = nowIso(input.now);
|
|
275
|
+
const run = this.db.transaction(() => {
|
|
276
|
+
const ch = this.db.prepare('SELECT * FROM prepared_challenges WHERE tenant_id = ? AND challenge_id = ?').get(this.tenant, input.challengeId);
|
|
277
|
+
if (!ch)
|
|
278
|
+
return { ok: false, reason: 'no-challenge' };
|
|
279
|
+
if (ch.status === 'prepared' && ch.expires_at < at)
|
|
280
|
+
return { ok: false, reason: 'challenge-expired' };
|
|
281
|
+
if (ch.status !== 'prepared')
|
|
282
|
+
return { ok: false, reason: 'challenge-not-prepared' };
|
|
283
|
+
if (sha256(input.deliveryNonce) !== ch.delivery_nonce_digest)
|
|
284
|
+
return { ok: false, reason: 'nonce-mismatch' };
|
|
285
|
+
if (input.actorRef !== ch.actor_ref || input.seam !== ch.seam)
|
|
286
|
+
return { ok: false, reason: 'binding-mismatch' };
|
|
287
|
+
if (this.isRevoked({ actorRef: input.actorRef, seam: input.seam }))
|
|
288
|
+
return { ok: false, reason: 'authority-revoked' };
|
|
289
|
+
const receiptId = 'rc-' + randomUUID();
|
|
290
|
+
this.db.prepare(`UPDATE prepared_challenges SET status = 'confirmed' WHERE tenant_id = ? AND challenge_id = ? AND status = 'prepared'`).run(this.tenant, input.challengeId);
|
|
291
|
+
this.db.prepare(`INSERT INTO approval_claims (tenant_id, receipt_id, challenge_id, idem_key, nonce_digest, request_fingerprint_sha256, actor_ref, principal_ref, seam, presentation_key, valid_until, issued_at, consumed_at, amount_total, currency)
|
|
292
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(this.tenant, receiptId, ch.challenge_id, ch.idem_key, ch.delivery_nonce_digest, ch.request_fingerprint_sha256, input.actorRef, ch.principal_ref, ch.seam, ch.presentation_key, ch.valid_until, at, ch.amount_total, ch.currency);
|
|
293
|
+
this.ledger.insertEvent({
|
|
294
|
+
actor: 'system:writegate',
|
|
295
|
+
kind: 'writegate.receipt_issued',
|
|
296
|
+
subjectId: ch.idem_key,
|
|
297
|
+
payload: { receipt_id: receiptId, challenge_id: ch.challenge_id, request_fingerprint_sha256: ch.request_fingerprint_sha256 },
|
|
298
|
+
idemKey: `wgr:${receiptId}`,
|
|
299
|
+
ts: at,
|
|
300
|
+
});
|
|
301
|
+
return { ok: true, receiptId, requestFingerprintSha256: ch.request_fingerprint_sha256 };
|
|
302
|
+
});
|
|
303
|
+
return run();
|
|
304
|
+
}
|
|
305
|
+
// ---- 事务1:原子消费 + outbox 入队 ----------------------------------------------
|
|
306
|
+
/**
|
|
307
|
+
* 事务1(write-gate-production-design §5.2 步骤3,单 SQLite 事务,不得与事务2合并):
|
|
308
|
+
* a. 读发行记录并验证 tenant/actor/principal/seam/receipt/challenge 一致 + 跨 intent 重放拒绝;
|
|
309
|
+
* b. 重算请求指纹,与发行记录字节级一致(呈现/确认后任何字段漂移 = 拒绝);
|
|
310
|
+
* c. valid_until 未过、presentation_key 匹配、nonce 未消费;
|
|
311
|
+
* d. UPDATE approval_claims SET consumed_at WHERE consumed_at IS NULL —— 恰 1 行(一次性消费);
|
|
312
|
+
* e. pending_writes pending→confirmed(复用账本 saga 面,携 receipt);
|
|
313
|
+
* f. INSERT write_effect_intents(dispatch_status='queued')。
|
|
314
|
+
* 任一步失败 → 整体回滚:无 claim 消费、无状态转移、outbox 零行、无 write.confirmed 事件。
|
|
315
|
+
*/
|
|
316
|
+
consumeApprovalAndEnqueue(input) {
|
|
317
|
+
const at = nowIso(input.now);
|
|
318
|
+
const run = this.db.transaction(() => {
|
|
319
|
+
const claim = this.db.prepare('SELECT * FROM approval_claims WHERE tenant_id = ? AND receipt_id = ?').get(this.tenant, input.receiptId);
|
|
320
|
+
if (!claim)
|
|
321
|
+
return { ok: false, reason: 'no-issuance-record' };
|
|
322
|
+
if (claim.idem_key !== input.idemKey)
|
|
323
|
+
return { ok: false, reason: 'cross-intent-replay' };
|
|
324
|
+
if (claim.consumed_at !== null)
|
|
325
|
+
return { ok: false, reason: 'approval-claimed' };
|
|
326
|
+
if (input.fields.actorRef !== claim.actor_ref || input.fields.principalRef !== claim.principal_ref || input.fields.seam !== claim.seam) {
|
|
327
|
+
return { ok: false, reason: 'binding-mismatch' };
|
|
328
|
+
}
|
|
329
|
+
if (claim.valid_until < at)
|
|
330
|
+
return { ok: false, reason: 'receipt-expired' };
|
|
331
|
+
if (this.isRevoked({ actorRef: claim.actor_ref, principalRef: claim.principal_ref, seam: claim.seam }))
|
|
332
|
+
return { ok: false, reason: 'authority-revoked' };
|
|
333
|
+
// b+c:指纹重算(nonce 摘要取自发行记录,不由调用方提供)——字段级漂移全部收敛到字节不等
|
|
334
|
+
const recomputed = computeRequestFingerprint(input.fields, claim.nonce_digest);
|
|
335
|
+
if (recomputed !== claim.request_fingerprint_sha256)
|
|
336
|
+
return { ok: false, reason: 'fingerprint-mismatch' };
|
|
337
|
+
if (input.fields.presentationKey !== claim.presentation_key)
|
|
338
|
+
return { ok: false, reason: 'fingerprint-mismatch' };
|
|
339
|
+
// 挑战必须已 confirmed(prepared 直接消费 = 拒绝;consumed 在 d 的单行约束兜底)
|
|
340
|
+
const ch = this.db.prepare('SELECT status FROM prepared_challenges WHERE tenant_id = ? AND challenge_id = ?').get(this.tenant, claim.challenge_id);
|
|
341
|
+
if (!ch || ch.status !== 'confirmed')
|
|
342
|
+
return { ok: false, reason: 'challenge-not-prepared' };
|
|
343
|
+
// d. 一次性消费(原子单行;并发/重放第二个到达者 changes=0 → approval-claimed)
|
|
344
|
+
const consumed = this.db.prepare('UPDATE approval_claims SET consumed_at = ? WHERE tenant_id = ? AND receipt_id = ? AND consumed_at IS NULL').run(at, this.tenant, input.receiptId);
|
|
345
|
+
if (consumed.changes !== 1)
|
|
346
|
+
throw new WriteGateRejectionError('approval-claimed');
|
|
347
|
+
// e. pending→confirmed(复用账本 saga 面:同事务携 receipt 事件;失败即回滚)
|
|
348
|
+
const confirmed = this.ledger.confirmPendingWrite(input.idemKey, input.receiptId, 'system:writegate');
|
|
349
|
+
if (!confirmed.ok)
|
|
350
|
+
throw new WriteGateRejectionError('confirm-failed');
|
|
351
|
+
// 挑战随之 consumed(字母表闭环;同事务)
|
|
352
|
+
this.db.prepare(`UPDATE prepared_challenges SET status = 'consumed' WHERE tenant_id = ? AND challenge_id = ? AND status = 'confirmed'`).run(this.tenant, claim.challenge_id);
|
|
353
|
+
// f. durable outbox 入队(物理约束:receipt 非空 CHECK + 跨租户复合外键 + queued 无领取字段)
|
|
354
|
+
const supplierAttemptKey = 'sa-' + randomUUID();
|
|
355
|
+
this.db.prepare(`INSERT INTO write_effect_intents (tenant_id, idem_key, effect_name, receipt_id, supplier_attempt_key, request_fingerprint_sha256, nonce_digest, actor_ref, principal_ref, seam, attempt_budget, next_action, dispatch_status, created, updated)
|
|
356
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatch', 'queued', ?, ?)`).run(this.tenant, input.idemKey, input.effectName, input.receiptId, supplierAttemptKey, claim.request_fingerprint_sha256, claim.nonce_digest, claim.actor_ref, claim.principal_ref, claim.seam, 1, at, at);
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
effect: { idem_key: input.idemKey, receipt_id: input.receiptId, supplier_attempt_key: supplierAttemptKey, dispatch_status: 'queued' },
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
// IMMEDIATE:消费事务是写事务,先取写锁再读,跨进程并发下不依赖快照升级(失败封闭到 approval-claimed)
|
|
364
|
+
return run.immediate();
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
if (e instanceof WriteGateRejectionError)
|
|
368
|
+
return { ok: false, reason: e.reason };
|
|
369
|
+
throw e;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// ---- 事务2:派发领取 + 派发前复验(机制层止于此;真实调用方 = #232,M5 Entry 后) ----
|
|
373
|
+
/**
|
|
374
|
+
* 事务2:原子领取。条件 UPDATE queued→dispatching,affected rows=1 唯一赢家;
|
|
375
|
+
* 同事务先于任何外部调用持久化不可变 attempt_id/fencing_token/claimed_by/lease_until。
|
|
376
|
+
* 输家(含跨进程竞争)changes=0,不得发起供应商调用。
|
|
377
|
+
*/
|
|
378
|
+
claimForDispatch(input) {
|
|
379
|
+
const at = nowIso(input.now);
|
|
380
|
+
const run = this.db.transaction(() => {
|
|
381
|
+
const intent = this.db.prepare('SELECT dispatch_status FROM write_effect_intents WHERE tenant_id = ? AND idem_key = ?').get(this.tenant, input.idemKey);
|
|
382
|
+
if (!intent)
|
|
383
|
+
return { ok: false, reason: 'missing-intent' };
|
|
384
|
+
if (intent.dispatch_status !== 'queued')
|
|
385
|
+
return { ok: false, reason: 'not-claimable' };
|
|
386
|
+
const attemptId = 'at-' + randomUUID();
|
|
387
|
+
const fencing = this.db.prepare('SELECT COALESCE(MAX(fencing_token), 0) + 1 AS t FROM write_effect_intents WHERE tenant_id = ? AND idem_key = ?').get(this.tenant, input.idemKey);
|
|
388
|
+
const claimed = this.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'dispatching', attempt_id = ?, fencing_token = ?, claimed_by = ?, lease_until = ?, updated = ?
|
|
389
|
+
WHERE tenant_id = ? AND idem_key = ? AND dispatch_status = 'queued'`).run(attemptId, fencing.t, input.claimedBy, input.leaseUntil, at, this.tenant, input.idemKey);
|
|
390
|
+
if (claimed.changes !== 1)
|
|
391
|
+
return { ok: false, reason: 'lost-race' };
|
|
392
|
+
this.ledger.insertEvent({
|
|
393
|
+
actor: 'system:writegate',
|
|
394
|
+
kind: 'writegate.dispatch_claimed',
|
|
395
|
+
subjectId: input.idemKey,
|
|
396
|
+
payload: { attempt_id: attemptId, fencing_token: fencing.t, claimed_by: input.claimedBy, lease_until: input.leaseUntil },
|
|
397
|
+
idemKey: `wgd:${attemptId}`,
|
|
398
|
+
ts: at,
|
|
399
|
+
});
|
|
400
|
+
return { ok: true, attempt_id: attemptId, fencing_token: fencing.t, lease_until: input.leaseUntil };
|
|
401
|
+
});
|
|
402
|
+
// IMMEDIATE:领取必须先取写锁——两个 dispatcher 竞争时,输家在 BEGIN 处排队后看到已派发状态,
|
|
403
|
+
// 统一 not-claimable/lost-race,绝不对陈旧快照做条件 UPDATE。
|
|
404
|
+
return run.immediate();
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 派发前复验(§5.5;赢取领取后、任何供应商/网络调用前的同一派发点):
|
|
408
|
+
* 授权(claim.consumed_at 仍指向本 intent)/报价与凭据有效期/不可变请求指纹/撤回状态。
|
|
409
|
+
* 任一失败且调用未出门:供应商写=0,旧效果置 rejected(outbox 层终态,永不回 queued)。
|
|
410
|
+
*/
|
|
411
|
+
preDispatchRevalidate(input) {
|
|
412
|
+
const at = nowIso(input.now);
|
|
413
|
+
const intent = this.db.prepare('SELECT * FROM write_effect_intents WHERE tenant_id = ? AND idem_key = ?').get(this.tenant, input.idemKey);
|
|
414
|
+
if (!intent)
|
|
415
|
+
return { ok: false, reason: 'missing-intent' };
|
|
416
|
+
if (intent.dispatch_status !== 'dispatching')
|
|
417
|
+
return { ok: false, reason: 'not-claimable' };
|
|
418
|
+
const claim = this.db.prepare('SELECT * FROM approval_claims WHERE tenant_id = ? AND receipt_id = ?').get(this.tenant, intent.receipt_id);
|
|
419
|
+
if (!claim || claim.consumed_at === null || claim.idem_key !== intent.idem_key)
|
|
420
|
+
return { ok: false, reason: 'no-issuance-record' };
|
|
421
|
+
if (claim.valid_until < at)
|
|
422
|
+
return { ok: false, reason: 'receipt-expired' };
|
|
423
|
+
if (claim.request_fingerprint_sha256 !== intent.request_fingerprint_sha256)
|
|
424
|
+
return { ok: false, reason: 'fingerprint-mismatch' };
|
|
425
|
+
if (input.fields) {
|
|
426
|
+
const recomputed = computeRequestFingerprint(input.fields, this.nonceDigestOf(intent.receipt_id));
|
|
427
|
+
if (recomputed !== intent.request_fingerprint_sha256)
|
|
428
|
+
return { ok: false, reason: 'fingerprint-mismatch' };
|
|
429
|
+
}
|
|
430
|
+
if (this.isRevoked({ actorRef: intent.actor_ref, principalRef: intent.principal_ref, seam: intent.seam }))
|
|
431
|
+
return { ok: false, reason: 'authority-revoked' };
|
|
432
|
+
return { ok: true };
|
|
433
|
+
}
|
|
434
|
+
/** 派发前复验失败 → outbox 层终态 rejected(零供应商写;物理触发器保证永不回 queued) */
|
|
435
|
+
rejectIntentBeforeDispatch(input) {
|
|
436
|
+
const at = nowIso(input.now);
|
|
437
|
+
const info = this.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'rejected', reject_reason = ?, next_action = 'manual-reconcile', updated = ?
|
|
438
|
+
WHERE tenant_id = ? AND idem_key = ? AND dispatch_status IN ('queued','dispatching')`).run(input.reason, at, this.tenant, input.idemKey);
|
|
439
|
+
if (info.changes !== 1)
|
|
440
|
+
return { ok: false, reason: 'not-claimable' };
|
|
441
|
+
this.ledger.insertEvent({ actor: 'system:writegate', kind: 'writegate.intent_rejected', subjectId: input.idemKey, payload: { reason: input.reason }, idemKey: `wgx:${input.idemKey}`, ts: at });
|
|
442
|
+
return { ok: true };
|
|
443
|
+
}
|
|
444
|
+
/** 复验通过、调用已出门 → dispatched(outbox 层记录;不是供应商成功凭据) */
|
|
445
|
+
markIntentDispatched(input) {
|
|
446
|
+
const at = nowIso(input.now);
|
|
447
|
+
const info = this.db.prepare(`UPDATE write_effect_intents SET dispatch_status = 'dispatched', updated = ? WHERE tenant_id = ? AND idem_key = ? AND dispatch_status = 'dispatching'`).run(at, this.tenant, input.idemKey);
|
|
448
|
+
return info.changes === 1 ? { ok: true } : { ok: false, reason: 'not-claimable' };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 崩溃/超时/租约过期 → unknown 口径:dispatching 保持 dispatching(物理上不得回 queued),
|
|
452
|
+
* next_action 置 query(只许查询/人工对账,禁止盲目重试)。
|
|
453
|
+
*/
|
|
454
|
+
markIntentUnknown(input) {
|
|
455
|
+
const at = nowIso(input.now);
|
|
456
|
+
const info = this.db.prepare(`UPDATE write_effect_intents SET next_action = 'query', updated = ? WHERE tenant_id = ? AND idem_key = ? AND dispatch_status = 'dispatching'`).run(at, this.tenant, input.idemKey);
|
|
457
|
+
if (info.changes !== 1)
|
|
458
|
+
return { ok: false, reason: 'not-claimable' };
|
|
459
|
+
this.ledger.insertEvent({ actor: 'system:writegate', kind: 'writegate.outcome_unknown', subjectId: input.idemKey, payload: { reason: input.reason }, idemKey: `wgu:${input.idemKey}`, ts: at });
|
|
460
|
+
return { ok: true };
|
|
461
|
+
}
|
|
462
|
+
// ---- L4 撤回(§9:撤回是账本事件,立即阻断未来副作用;已执行效果只能走补偿) ----------
|
|
463
|
+
revokeAuthority(input) {
|
|
464
|
+
const at = nowIso(input.now);
|
|
465
|
+
this.db.prepare(`INSERT INTO write_gate_revocations (tenant_id, actor_ref, principal_ref, seam, reason, revoked_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
466
|
+
ON CONFLICT(tenant_id, actor_ref, principal_ref, seam) DO UPDATE SET reason = excluded.reason, revoked_at = excluded.revoked_at`).run(this.tenant, input.actorRef ?? '*', input.principalRef ?? '*', input.seam ?? '*', input.reason, at);
|
|
467
|
+
this.ledger.insertEvent({ actor: 'system:writegate', kind: 'writegate.revoked', subjectId: '*', payload: { actor_ref: input.actorRef ?? '*', principal_ref: input.principalRef ?? '*', seam: input.seam ?? '*', reason: input.reason }, idemKey: `wgv:${at}:${randomUUID()}`, ts: at });
|
|
468
|
+
return { ok: true };
|
|
469
|
+
}
|
|
470
|
+
isRevoked(scope) {
|
|
471
|
+
const row = this.db.prepare(`SELECT 1 FROM write_gate_revocations WHERE tenant_id = ?
|
|
472
|
+
AND (actor_ref = '*' OR actor_ref = ?)
|
|
473
|
+
AND (principal_ref = '*' OR principal_ref = ?)
|
|
474
|
+
AND (seam = '*' OR seam = ?)
|
|
475
|
+
LIMIT 1`).get(this.tenant, scope.actorRef ?? '*', scope.principalRef ?? '*', scope.seam ?? '*');
|
|
476
|
+
return row !== undefined;
|
|
477
|
+
}
|
|
478
|
+
// ---- 只读面(对账/审计) --------------------------------------------------------
|
|
479
|
+
getIntent(idemKey) {
|
|
480
|
+
return this.db.prepare('SELECT * FROM write_effect_intents WHERE tenant_id = ? AND idem_key = ?').get(this.tenant, idemKey);
|
|
481
|
+
}
|
|
482
|
+
getClaim(receiptId) {
|
|
483
|
+
return this.db.prepare('SELECT * FROM approval_claims WHERE tenant_id = ? AND receipt_id = ?').get(this.tenant, receiptId);
|
|
484
|
+
}
|
|
485
|
+
getChallenge(challengeId) {
|
|
486
|
+
return this.db.prepare('SELECT * FROM prepared_challenges WHERE tenant_id = ? AND challenge_id = ?').get(this.tenant, challengeId);
|
|
487
|
+
}
|
|
488
|
+
listIntents() {
|
|
489
|
+
return this.db.prepare('SELECT * FROM write_effect_intents WHERE tenant_id = ? ORDER BY created').all(this.tenant);
|
|
490
|
+
}
|
|
491
|
+
nonceDigestOf(receiptId) {
|
|
492
|
+
const row = this.db.prepare('SELECT nonce_digest FROM approval_claims WHERE tenant_id = ? AND receipt_id = ?').get(this.tenant, receiptId);
|
|
493
|
+
return row?.nonce_digest ?? '';
|
|
494
|
+
}
|
|
495
|
+
}
|