@sema-agent/server 7.43.0 → 7.44.0
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/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,74 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* #154 车二 —— **持久化权限规则店的 SQL 双方言实装**(core 5.18.0 design/179 + 5.22.0 design/182)。
|
|
3
|
-
*
|
|
4
|
-
* 三张表,三个不同的问题:
|
|
5
|
-
* · `permission_rule` —— 一只**桶**(owner)的整份规则状态 + 同步游标面(actor/counter/rev/
|
|
6
|
-
* observed_vector),OCC 靠 `rev` 列。
|
|
7
|
-
* · `permission_rule_approval` —— core `RuleApprovalRecordStore` 的 SQL 形(durable 审批记录,CAS on rev)。
|
|
8
|
-
* · `permission_rule_ticket` —— **server 自铸**的 CC 导入票(principal 绑定 + TTL + 一次性原子消费 +
|
|
9
|
-
* 载荷绑定)。core 的 `RuleTicket` 没有这四样中的任何一样(亲读
|
|
10
|
-
* `mintRuleTicket` = `rt.<index>.<recordId>`,零 TTL、零签名、零消费位),
|
|
11
|
-
* 所以它们是 **server 附加的信任边界**,自建格。
|
|
12
|
-
*
|
|
13
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
14
|
-
* 🔴 为什么 `permission_rule` 是**一桶一行**而不是「一规则一行」
|
|
15
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
16
|
-
* 这个店的**每一个**操作都是全量的:`list()` 读整份、`readRaw()` 读整份、`apply(delta,{expectedRev})`
|
|
17
|
-
* 是**整份状态的一次 OCC 写**(core 明写「Partial landing 在写层不可拼写:要么整个 join 落地、要么一点
|
|
18
|
-
* 都不落」)。core 自己对同族问题的裁定逐字在 `RuleSyncFrontier` 的 JSDoc 里:「persist this as a JSON
|
|
19
|
-
* blob column —— the vector is read and written whole, never queried per-actor; **a row-per-actor table
|
|
20
|
-
* would invite partial updates the semantics cannot express**」。规则集与它是同一种量:CRDT 的收敛性
|
|
21
|
-
* 建立在「整份状态的 join」上,把它拆成行会让「半个 join 落地」在 SQL 层**变得可表达**——而那正是语义
|
|
22
|
-
* 禁止的形。一桶一行还带来一条真收益:OCC 写退化成**单条** `UPDATE … WHERE owner_key=? AND rev=?`,
|
|
23
|
-
* 天然原子,不需要事务、不需要 `FOR UPDATE`、不进本仓在案的悲观锁死锁族。
|
|
24
|
-
*
|
|
25
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
26
|
-
* 🔴 写面 = **core 的本尊**(A-033.2 / #256 销账,交接件③已兑现)
|
|
27
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
28
|
-
* 本文件曾整段登记一个上游契约缺口:core 的写面(`PERMISSION_RULE_WRITER` 常量、`PermissionRuleWriter`/
|
|
29
|
-
* `WritablePermissionRuleStore`/`RuleWriteDelta`/`RawRuleSyncState` 类型、`foldDelta`/`writerOf`/
|
|
30
|
-
* `assertRedemptionNotQuarantined`/`assertDeleteDeltaCarriesNoAdd` 函数)当时**不在包的公开导出面上**,
|
|
31
|
-
* 于是本文件按结构满足它:属性名用字面常量、类型在下方一份份**本地镜像**(`RuleWriteDeltaMirror` 等),
|
|
32
|
-
* 并把「求 core 导出写面」列为交接件③。
|
|
33
|
-
*
|
|
34
|
-
* core 2026-08-10 裁定**导出后端契约**(d.ts 原话:「Exported as part of the BACKEND CONTRACT … so an
|
|
35
|
-
* out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
|
|
36
|
-
* types」),5.33.0 起全部根导出 ⇒ 镜像与手写孪生一并摘除,本文件直接消费 core 的类型与三个共享判据。
|
|
37
|
-
* 这不是洁癖:`foldDelta` 的 d.ts 自陈「Pure — the backends share it so the file and in-memory forms
|
|
38
|
-
* cannot drift in what a delta MEANS」,而本 backend 此前**没有共享它**——一份 delta 在 file 后端与
|
|
39
|
-
* SQL 后端上「意味着什么」可以各自漂,漂的落点是放行面。换装同时把两条判据换成 core 的严格版:
|
|
40
|
-
* · `assertDeleteDeltaCarriesNoAdd`:core 查 **6 个** add-only 键(add/redemption/rule/command/match/tool)
|
|
41
|
-
* 并要求 `removedDots` 是**非空数组**;本地孪生只查过一个 `rules` 键(那还是 sync-join 的字段名,
|
|
42
|
-
* 打偏了)且对空 `removedDots` 放行 —— 一条删不掉任何 add 的墓碑会让本该已删的规则继续放行。
|
|
43
|
-
* · `assertRedemptionNotQuarantined`:语义等价(同 dot 即拒),换本尊后报文也与 file 后端一致。
|
|
44
|
-
*
|
|
45
|
-
* 仍然**不实现**的只剩 `sync-join` 那一臂:它的落地管线(`applySyncJoin`)至今是 core 私有的一整条
|
|
46
|
-
* 流水线,复制它才是真的会漂;本 backend 对该 delta **响亮拒绝**(fail-closed,不是静默 no-op),
|
|
47
|
-
* 同步客户端本仓尚未接线,所以这条臂今天零调用点。
|
|
48
|
-
*
|
|
49
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
50
|
-
* 边界纪律:列回读一律 zod `safeParse`(宪法 [2704] 禁裸 as-cast)。JSON 列是驱动交回的 `unknown`,
|
|
51
|
-
* 一条形状漂了的历史行若被 as-cast 成 `PersistedAllowRule[]`,会带着 `undefined` 字段一路走进
|
|
52
|
-
* `findAdmittingRule` —— 那是**放行面**上的静默垃圾,比读不出来坏得多。读不出来 ⇒ **throw**
|
|
53
|
-
* (core 明令:「a backend that fails to read must not silently report an empty set … it throws」)。
|
|
54
|
-
*/
|
|
55
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
56
2
|
import { z } from "zod";
|
|
57
3
|
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
|
|
58
4
|
import { dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
59
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
60
|
-
// 写面(core 的后端契约,2026-08-10 裁定导出;本仓 A-033.2 已摘除全部本地镜像)
|
|
61
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
-
/**
|
|
63
|
-
* 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
|
|
64
|
-
*
|
|
65
|
-
* 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
|
|
66
|
-
* 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
|
|
67
|
-
* 就该当场判 `undefined`,而不是在调用 `readRaw()` 时炸一个 `not a function`。
|
|
68
|
-
*
|
|
69
|
-
* 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
|
|
70
|
-
* 也是三份会各自漂的形状假设。
|
|
71
|
-
*/
|
|
72
5
|
export function writerOfSqlRuleStore(store) {
|
|
73
6
|
const w = Reflect.get(store, PERMISSION_RULE_WRITER);
|
|
74
7
|
if (w === null || typeof w !== "object")
|
|
@@ -80,18 +13,10 @@ export function writerOfSqlRuleStore(store) {
|
|
|
80
13
|
return undefined;
|
|
81
14
|
return { nextDot: nextDot, apply: apply, readRaw: readRaw };
|
|
82
15
|
}
|
|
83
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
84
|
-
// 列回读的边界 schema(宪法 [2704]:边界必 schema,禁裸 as-cast)
|
|
85
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
86
16
|
const RuleScopeSchema = z.union([
|
|
87
17
|
z.object({ kind: z.literal("global") }).strict(),
|
|
88
|
-
// root 非空:一条 `{kind:"project", root:""}` 的规则在 `pathWithinRoot` 下会**覆盖每一个 cwd**
|
|
89
|
-
// (空前缀包含一切)—— 那是把一条项目内规则悄悄升成全局规则(codex round1 [high] 二)。
|
|
90
18
|
z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
|
|
91
19
|
]);
|
|
92
|
-
/** dot 的两位都是**身份**:actor 非空串,counter 是安全范围内的非负整数。
|
|
93
|
-
* (codex 交叉复审 round1 [high] 二:裸 `z.number()` 放行 `NaN` / `1e308` / 小数 —— 一个 `NaN` counter
|
|
94
|
-
* 会让 dot 身份比较恒不相等,tombstone 从此删不掉它对应的 add。) */
|
|
95
20
|
const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
|
|
96
21
|
const RuleAddSchema = z
|
|
97
22
|
.object({ dot: RuleDotSchema, origin: z.enum(["user", "imported-cc", "starter"]), createdAt: z.string() })
|
|
@@ -106,20 +31,12 @@ const PersistedAllowRuleSchema = z
|
|
|
106
31
|
adds: z.array(RuleAddSchema),
|
|
107
32
|
})
|
|
108
33
|
.strict();
|
|
109
|
-
/** 🔴 `removedDots` **非空**(codex 交叉复审 round2 [high],验真后修):core 的落地管线对空 removedDots
|
|
110
|
-
* 的 tombstone 是**抛错**(「a protocol violation」),而它落到读面上的后果更隐蔽 —— 一条删不掉任何
|
|
111
|
-
* add 的墓碑会让一条**本该已删**的规则继续放行。`removePersistedRule` 在没有可删对象时压根不铸墓碑,
|
|
112
|
-
* 所以健康数据恒满足这一条;不满足的只可能是版本回退/还原/另一个写者。 */
|
|
113
34
|
const RuleTombstoneSchema = z
|
|
114
35
|
.object({ rule: z.string(), scope: RuleScopeSchema, removedDots: z.array(RuleDotSchema).min(1), deletedBy: RuleDotSchema })
|
|
115
36
|
.strict();
|
|
116
|
-
/** 隔离原因是 core 的**闭集**(`RULE_SYNC_DROP_CODES`)——词表从 core 派生,core 加员时本 schema 自动跟,
|
|
117
|
-
* 绝不手抄一份(手抄的那份会在 core 加员的那天静默拒收一整类合法行)。 */
|
|
118
37
|
const QUARANTINE_REASONS = new Set(Object.entries(RULE_SYNC_DROP_CODES)
|
|
119
38
|
.filter(([, side]) => side.includes("local-quarantined"))
|
|
120
39
|
.map(([code]) => code));
|
|
121
|
-
/** 🔴 词表从 core 的表**派生**(而不是抄一份字面量):`RuleQuarantineReason` 是 `RULE_SYNC_DROP_CODES`
|
|
122
|
-
* 中「会把 LOCAL 行搬进隔离区」的那一侧。抄一份的那天 core 加员,本 schema 会静默拒收一整类合法行。 */
|
|
123
40
|
const isQuarantineReason = (v) => typeof v === "string" && QUARANTINE_REASONS.has(v);
|
|
124
41
|
const QuarantinedRuleAddSchema = z
|
|
125
42
|
.object({
|
|
@@ -133,26 +50,10 @@ const QuarantinedRuleAddSchema = z
|
|
|
133
50
|
const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
|
|
134
51
|
const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
|
|
135
52
|
const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
|
|
136
|
-
|
|
137
|
-
// DDL
|
|
138
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
const RuleEditedSchema = z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict();
|
|
139
54
|
export const PERMISSION_RULE_TABLE = "permission_rule";
|
|
140
55
|
export const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
|
|
141
56
|
export const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
|
|
142
|
-
/**
|
|
143
|
-
* MySQL-protocol(TiDB)侧的三条建表语句。真源在本文件(与 PG twin 并排,方言差异一眼可对);
|
|
144
|
-
* `tidb-pool.ts` 的 `SCHEMA_STATEMENTS` 用 `...TIDB_PERMISSION_RULE_STATEMENTS` 展开,于是三张表跟着
|
|
145
|
-
* 中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建(同 `TIDB_APPROVAL_ASK_STATEMENTS` 先例)。
|
|
146
|
-
*
|
|
147
|
-
* 列宽依据(#192 A10 的「每列的值由谁铸、有没有入口上限」口径):
|
|
148
|
-
* · `owner_key` / `record_id` / `ticket_id` / `approval_id` / `payload_hash` / `checksum` → 190:
|
|
149
|
-
* 全是本仓自铸的键类值(sha256 hex 64 / uuid 36 / `sha256:` 前缀 71),190 是全仓键轴的统一宽度。
|
|
150
|
-
* · `principal` → 512:principal 入口上限是 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts`),这里给
|
|
151
|
-
* 名类宽度 512 是**上界宽于入口**的方向(绝不制造静默截断面)。
|
|
152
|
-
* · `actor` → 190:本 backend 自铸的副本身份(`sql-<12 hex>`),按构造 ≤ 20 字符。
|
|
153
|
-
* · 内容列一律 LONGTEXT / TEXT:一只桶的规则集没有硬上限(导入一份大 settings 就能过 64K TEXT 墙)。
|
|
154
|
-
* · 毫秒列一律 `_ms` 后缀;OCC 列一律叫 `rev`(`version` 是形/schema 版本的保留词)。
|
|
155
|
-
*/
|
|
156
57
|
export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
157
58
|
`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TABLE} (
|
|
158
59
|
owner_key VARCHAR(190) NOT NULL,
|
|
@@ -190,6 +91,13 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
190
91
|
redeemed_dots_json LONGTEXT NULL,
|
|
191
92
|
tool_call_id VARCHAR(255) NULL,
|
|
192
93
|
bound_input_hash VARCHAR(255) NULL,
|
|
94
|
+
-- #340:卡记录上**被裁决命令的原字节**(core 卡编辑面覆盖门的输入:「编辑后的规则必须仍然容纳
|
|
95
|
+
-- 这条命令」)。LONGTEXT:命令行没有入口上限,VARCHAR 会造一个静默截断面,而截断过的命令与原命令
|
|
96
|
+
-- 是两条不同的命令 —— 覆盖门会据一条**我们自己改过**的命令做判断。batch 记录上恒 NULL。
|
|
97
|
+
command LONGTEXT NULL,
|
|
98
|
+
-- #340:那条人手改的候选的出身(\`{index,text,at}\` 整读整写)。两个臂(decision-edited /
|
|
99
|
+
-- offered-redeemed)在审计面的**唯一**判别位;core 也据它做同一份编辑的幂等重放。
|
|
100
|
+
edited_json LONGTEXT NULL,
|
|
193
101
|
-- created_at_iso:core 的 \`RuleApprovalRecord.createdAt\` 是 ISO **字符串**,逐字回放(不是毫秒列,
|
|
194
102
|
-- 故不带 _ms 后缀);排序/清扫另用 created_at_ms。
|
|
195
103
|
created_at_iso VARCHAR(64) NOT NULL,
|
|
@@ -225,12 +133,36 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
225
133
|
KEY idx_permission_rule_ticket_expires (expires_at_ms)
|
|
226
134
|
) COLLATE utf8mb4_bin`,
|
|
227
135
|
];
|
|
228
|
-
/** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
|
|
229
|
-
* 本函数留给只需要这三张表的集成测试)。 */
|
|
230
136
|
export async function ensureTiDBPermissionRuleSchema(pool) {
|
|
231
137
|
for (const stmt of TIDB_PERMISSION_RULE_STATEMENTS)
|
|
232
138
|
await pool.query(stmt);
|
|
233
139
|
}
|
|
140
|
+
function isMissingColumnError(err, dialect) {
|
|
141
|
+
if (err === null || typeof err !== "object")
|
|
142
|
+
return false;
|
|
143
|
+
const code = Reflect.get(err, "code");
|
|
144
|
+
if (dialect === "pg")
|
|
145
|
+
return code === "42703";
|
|
146
|
+
return Reflect.get(err, "errno") === 1054 || code === "ER_BAD_FIELD_ERROR";
|
|
147
|
+
}
|
|
148
|
+
export async function assertPermissionRuleApprovalSchema(query, dialect) {
|
|
149
|
+
try {
|
|
150
|
+
await query(`SELECT command, edited_json FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (!isMissingColumnError(err, dialect))
|
|
154
|
+
throw err;
|
|
155
|
+
throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} is missing the #340 card-edit columns (command / edited_json) — refusing to start. ` +
|
|
156
|
+
`The approval card now accepts a person-EDITED rule text, and the engine's coverage gate reads the adjudicated ` +
|
|
157
|
+
`command back off this record; the record also carries which candidate was edited. This repository ships no ` +
|
|
158
|
+
`ALTER TABLE migrations, so the table must be recreated: run \`DROP TABLE ${PERMISSION_RULE_APPROVAL_TABLE};\` on the ` +
|
|
159
|
+
`${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the schema is recreated at boot. ` +
|
|
160
|
+
`The cost is bounded: this table holds pending/settled approval RECORDS (the audit trail of who said yes to which ` +
|
|
161
|
+
`rule), not the rules themselves — persisted rules live in ${PERMISSION_RULE_TABLE} and are untouched. Dropping it ` +
|
|
162
|
+
`loses in-flight CC-import previews (re-runnable) and the historical record trail. ` +
|
|
163
|
+
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
234
166
|
export async function ensurePgPermissionRuleSchema(q) {
|
|
235
167
|
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TABLE} (
|
|
236
168
|
owner_key VARCHAR(190) COLLATE "C" NOT NULL,
|
|
@@ -262,15 +194,15 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
262
194
|
redeemed_dots_json TEXT COLLATE "C",
|
|
263
195
|
tool_call_id VARCHAR(255) COLLATE "C",
|
|
264
196
|
bound_input_hash VARCHAR(255) COLLATE "C",
|
|
197
|
+
-- #340 两格(MySQL 孪生的行内注写了理由:命令原字节是覆盖门的输入、edited 是两臂的判别位)。
|
|
198
|
+
command TEXT COLLATE "C",
|
|
199
|
+
edited_json TEXT COLLATE "C",
|
|
265
200
|
created_at_iso VARCHAR(64) COLLATE "C" NOT NULL,
|
|
266
201
|
created_at_ms BIGINT NOT NULL,
|
|
267
202
|
updated_at_ms BIGINT NOT NULL,
|
|
268
203
|
PRIMARY KEY (record_id)
|
|
269
204
|
)`);
|
|
270
205
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_owner ON ${PERMISSION_RULE_APPROVAL_TABLE} (owner_key)`);
|
|
271
|
-
// 保留期腿的支撑索引(MySQL 孪生的行内注写了理由:这张表按设计永久留审计事实,无索引的清扫谓词
|
|
272
|
-
// 是一次随史增长的全表扫)。PG 侧是独立 CREATE INDEX —— 存量库上 `IF NOT EXISTS` 会**真的补建**,
|
|
273
|
-
// 与 MySQL 侧「索引写在 CREATE TABLE 里、存量表拿不到」的不对称如实登记在 reapExpired 的注里。
|
|
274
206
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_sweep ON ${PERMISSION_RULE_APPROVAL_TABLE} (state, created_at_ms)`);
|
|
275
207
|
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
|
|
276
208
|
ticket_id VARCHAR(190) COLLATE "C" NOT NULL,
|
|
@@ -287,30 +219,13 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
287
219
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_owner ON ${PERMISSION_RULE_TICKET_TABLE} (owner_key)`);
|
|
288
220
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_expires ON ${PERMISSION_RULE_TICKET_TABLE} (expires_at_ms)`);
|
|
289
221
|
}
|
|
290
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
291
|
-
// 纯函数(桶键 / 摘要 / delta 折叠)
|
|
292
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
293
|
-
/**
|
|
294
|
-
* 一只桶的**存储键**(纯数据 ⇒ `build*`)。
|
|
295
|
-
*
|
|
296
|
-
* 🔴 为什么 hash 而不是把 principal 直接当主键:①principal 是自由文本身份串,做主键会让**长度**与
|
|
297
|
-
* **排序规则**变成安全面(一个 191 字符的 principal 在 190 宽的列上被静默截断 ⇒ 两个身份共用一只桶,
|
|
298
|
-
* 那是一次跨租户放行);②`local-owner` 那一员**没有** principal,需要一个同域的定长表示。
|
|
299
|
-
* 种别前缀让两族键不可能相撞(`principal:` vs `local-owner`)。
|
|
300
|
-
*/
|
|
301
222
|
export function buildRuleOwnerKey(owner) {
|
|
302
223
|
const material = owner.kind === "principal" ? `principal:${owner.principal}` : "local-owner";
|
|
303
224
|
return createHash("sha256").update(material, "utf8").digest("hex");
|
|
304
225
|
}
|
|
305
|
-
/** 内容三列 + 观测向量的完整性指纹(core `ruleStoreChecksum` 同精神;**不含** rev/counter —— 那两样
|
|
306
|
-
* 是元数据轴,`nextDot` 只动 counter 就不该迫使重算内容指纹)。
|
|
307
|
-
*
|
|
308
|
-
* 导出(纯数据 ⇒ `build*`)是为了让**取证测试**能造一条「指纹自洽但语义矛盾」的行 —— 那正是
|
|
309
|
-
* codex round1 [high] 二的形:一条只靠指纹是拦不住的坏行,必须由语义筛拦。 */
|
|
310
226
|
export function buildRuleBucketChecksum(body) {
|
|
311
227
|
return `sha256:${createHash("sha256").update(canonicalJson(body), "utf8").digest("hex")}`;
|
|
312
228
|
}
|
|
313
|
-
/** 稳定序 JSON(键排序递归)——摘要必须与键序无关,否则同一份内容会算出两个指纹。 */
|
|
314
229
|
function canonicalJson(v) {
|
|
315
230
|
if (v === null || typeof v !== "object")
|
|
316
231
|
return JSON.stringify(v) ?? "null";
|
|
@@ -321,10 +236,7 @@ function canonicalJson(v) {
|
|
|
321
236
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
322
237
|
return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${canonicalJson(val)}`).join(",")}}`;
|
|
323
238
|
}
|
|
324
|
-
/** {@link SqlPermissionRuleStore} 的 dot 铸造 CAS 重试上限。有界:一个无界循环只会把「争不过」变成
|
|
325
|
-
* 挂死;用尽 ⇒ 响亮抛错,由 core 的兑付腿(它自己也有有界重试)如实回 refused。 */
|
|
326
239
|
const NEXT_DOT_MAX_ATTEMPTS = 8;
|
|
327
|
-
/** 空桶的读出形(core `EMPTY_READ` 同值:桶不存在 = 零规则,不是错误)。 */
|
|
328
240
|
const EMPTY_BUCKET = { actor: "", counter: 0, rev: 0, rules: [], tombstones: [], quarantined: [] };
|
|
329
241
|
function parseColumn(schema, raw, column, ownerKey) {
|
|
330
242
|
const text = typeof raw === "string" ? raw : raw === null || raw === undefined ? undefined : String(raw);
|
|
@@ -342,9 +254,6 @@ function parseColumn(schema, raw, column, ownerKey) {
|
|
|
342
254
|
throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
|
|
343
255
|
return parsed.data;
|
|
344
256
|
}
|
|
345
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
346
|
-
// 店本体
|
|
347
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
348
257
|
class SqlPermissionRuleStore {
|
|
349
258
|
db;
|
|
350
259
|
owner;
|
|
@@ -364,7 +273,6 @@ class SqlPermissionRuleStore {
|
|
|
364
273
|
q(tidbSql, pgSql) {
|
|
365
274
|
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
366
275
|
}
|
|
367
|
-
/** 桶行的读出。不存在 ⇒ {@link EMPTY_BUCKET}(零规则,不是错误);存在但形不合/指纹不符 ⇒ **throw**。 */
|
|
368
276
|
async read() {
|
|
369
277
|
const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
|
|
370
278
|
const row = rows[0];
|
|
@@ -388,20 +296,6 @@ class SqlPermissionRuleStore {
|
|
|
388
296
|
throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
|
|
389
297
|
`refusing the whole bucket rather than reporting a partial rule set`);
|
|
390
298
|
}
|
|
391
|
-
// 🔴 codex 交叉复审 round1 [high] 二(验真后修):**形对 ≠ 语义安全**。
|
|
392
|
-
// 上面的 zod 只保证「这是一条 PersistedAllowRule 的形状」,指纹只保证「这些字节没被改过」——
|
|
393
|
-
// 两者都拦不住一条**自洽但语义矛盾**的行:`rule: "Bash(ls)"` 配 `match:"prefix", command:"rm"`。
|
|
394
|
-
// 而 core 的匹配器(`ruleAdmitsCommand`/`findAdmittingRule`)信的是 `match`/`command` 两个**去规范化**
|
|
395
|
-
// 字段,不重新解析 `rule` ⇒ 这样一行会放行一条它显示的规则根本没描述的命令。成因不必是攻击:
|
|
396
|
-
// 版本回退、备份还原、另一个写者,都会造出它。
|
|
397
|
-
// 判据用 core **导出的** `screenRuleSyncState`(它内部就是 design/182 §3.1 的 `normalizePersistedRule`:
|
|
398
|
-
// 从 `rule` 文本经**同一个**验证器重投影,与存的字段逐字比对)—— 不是我方另写一份等式(那必然会漂)。
|
|
399
|
-
// 处置 = **整桶拒绝**(throw),与上面的指纹臂同姿势:core 明令「refused (fail-closed), never repaired
|
|
400
|
-
// toward either side」,而修一半、留一半会让「这只桶到底有哪些规则」变成两个答案。
|
|
401
|
-
// 🔴 codex round2 [high](验真后修):`screenRuleSyncState` 只筛 **allow 行**,不看墓碑 ——
|
|
402
|
-
// 而墓碑的 `rule` 文本正是它与被删行的**唯一**配对键(`applyTombstones` 按 (rule, scope) 归组)。
|
|
403
|
-
// 一条文本非规范/验证器拒收的墓碑配不上任何一行 ⇒ 它删不掉任何东西,而调用方以为删掉了:
|
|
404
|
-
// 一条**本该已删**的规则继续放行。判据同样用 core 导出的**那一个**验证器(不是另写一份等式)。
|
|
405
299
|
const badTombstone = tombstones.find((t) => {
|
|
406
300
|
const parsed = parseAllowRuleText(t.rule);
|
|
407
301
|
return "reject" in parsed || parsed.rule.rule !== t.rule;
|
|
@@ -418,8 +312,6 @@ class SqlPermissionRuleStore {
|
|
|
418
312
|
}
|
|
419
313
|
return state;
|
|
420
314
|
}
|
|
421
|
-
/** 桶行的**创建**(幂等)。actor 是本 backend 自铸的**副本身份**(design/182:replica identity,
|
|
422
|
-
* NEVER a person identity)——一只桶一个,首次落行时定,之后永不变。 */
|
|
423
315
|
async ensureBucket() {
|
|
424
316
|
const nowMs = this.now();
|
|
425
317
|
const empty = { rules: [], tombstones: [], quarantined: [] };
|
|
@@ -449,21 +341,6 @@ class SqlPermissionRuleStore {
|
|
|
449
341
|
return this.writer;
|
|
450
342
|
}
|
|
451
343
|
writer = {
|
|
452
|
-
/**
|
|
453
|
-
* 铸下一个 dot。**方言同形的有界 CAS 循环**:读到 `counter=c` ⇒ 打
|
|
454
|
-
* `UPDATE … SET counter = c+1 WHERE owner_key = ? AND counter = c`,`affected === 1` 才认领 `c+1`。
|
|
455
|
-
*
|
|
456
|
-
* 🔴 为什么不是「事务 + `SELECT … FOR UPDATE`」(第一版就是那样,真双库当场红):
|
|
457
|
-
* · `BEGIN PESSIMISTIC` 是 **TiDB 方言**,真 MySQL 上是语法错误(本机 MySQL 9.x 直接拒);
|
|
458
|
-
* · 换成裸 `BEGIN` 也只是换一种锁形,却把一条**悲观锁腿**引进本仓在案的死锁族(MySQL vs TiDB
|
|
459
|
-
* 的锁语义分歧),而这一格根本不需要锁:分配一个单调计数器正是 CAS 的教科书形。
|
|
460
|
-
* 🔴 也不是 `SET counter = counter + 1` 那条「原子自增」:它在引擎里确实原子,但 mysql2 **没有**
|
|
461
|
-
* `RETURNING`,自增之后要另起一次 SELECT 才知道自己拿到了几 —— 那一跳之间又是一次竞态。
|
|
462
|
-
* CAS 把「加一」与「知道加到了几」变成同一件事,两个方言逐字同形。
|
|
463
|
-
*
|
|
464
|
-
* 一次 read-then-write(读 counter、加一、无条件写)会在两个并发兑付上发出**同一个 dot** ——
|
|
465
|
-
* 那等于把两次人的同意折成一次 add,是这段代码存在的全部理由。
|
|
466
|
-
*/
|
|
467
344
|
nextDot: async () => {
|
|
468
345
|
await this.ensureBucket();
|
|
469
346
|
for (let attempt = 0; attempt < NEXT_DOT_MAX_ATTEMPTS; attempt++) {
|
|
@@ -493,8 +370,6 @@ class SqlPermissionRuleStore {
|
|
|
493
370
|
};
|
|
494
371
|
},
|
|
495
372
|
apply: async (delta, opts) => {
|
|
496
|
-
// 🔴 fail-closed(顶注 ②):同步落地管线是 core 私有的一整条流水线,复制它才是真的会漂。
|
|
497
|
-
// 响亮拒绝而不是静默 no-op —— 静默 no-op 会让一个「join 已落地」的调用方带着错误的信念继续。
|
|
498
373
|
if (delta.kind === "sync-join") {
|
|
499
374
|
throw new Error("the SQL permission-rule backend does not implement the sync-join landing pipeline (core's applySyncJoin is not on the package's public export face); " +
|
|
500
375
|
"no sync client is wired in this deployment, so this arm has no caller — see permission-rule-store-sql.ts header");
|
|
@@ -505,8 +380,6 @@ class SqlPermissionRuleStore {
|
|
|
505
380
|
return { conflict: true, rev: cur.rev };
|
|
506
381
|
let nextRules = cur.rules;
|
|
507
382
|
let nextTombstones = cur.tombstones;
|
|
508
|
-
// A-033.2(#256):两条判据 + 折叠本体全部走 core 的共享件(`foldDelta` 的 d.ts:「the backends share
|
|
509
|
-
// it so the file and in-memory forms cannot drift in what a delta MEANS」)。此前这三处是本地孪生。
|
|
510
383
|
if (delta.kind === "redemption-add") {
|
|
511
384
|
assertRedemptionNotQuarantined(cur.quarantined, delta);
|
|
512
385
|
nextRules = foldDelta(cur.rules, delta);
|
|
@@ -515,19 +388,12 @@ class SqlPermissionRuleStore {
|
|
|
515
388
|
assertDeleteDeltaCarriesNoAdd(delta);
|
|
516
389
|
nextTombstones = [...cur.tombstones, delta.tombstone];
|
|
517
390
|
}
|
|
518
|
-
// 🔴 codex 交叉复审 round5 [high] 一(验真后修)——**只写读得回来的东西**。
|
|
519
|
-
// 读面比写面严(空 root 的 project scope、空 removedDots 的墓碑…都会让整桶拒),于是一个宽松的
|
|
520
|
-
// 上游入口可以写进一条**自己再也读不回来**的行:一次「成功」的导入把这位租户的桶永久毒死。
|
|
521
|
-
// 判据用的是**同一组** schema(不是另写一份等式),所以两面永不可能各自漂;违反 ⇒ 响亮拒绝,
|
|
522
|
-
// 绝不落盘(fail-closed:宁可这次写失败,也不留一条让整桶不可读的行)。
|
|
523
391
|
const writable = z.object({ rules: z.array(PersistedAllowRuleSchema), tombstones: z.array(RuleTombstoneSchema) }).safeParse({ rules: nextRules, tombstones: nextTombstones });
|
|
524
392
|
if (!writable.success) {
|
|
525
393
|
throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
|
|
526
394
|
}
|
|
527
395
|
const body = { rules: nextRules, tombstones: nextTombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
|
|
528
396
|
const nowMs = this.now();
|
|
529
|
-
// OCC = **单条条件 UPDATE**(`WHERE owner_key=? AND rev=?`)。affected===0 ⇒ 有人先落了,如实回
|
|
530
|
-
// conflict + 当前 rev(调用方重读重试;core 的 applyRedemption 自带有界重试循环)。
|
|
531
397
|
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = ?, tombstones_json = ?, quarantined_json = ?, checksum = ?, rev = rev + 1, updated_at_ms = ? WHERE owner_key = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = $1, tombstones_json = $2, quarantined_json = $3, checksum = $4, rev = rev + 1, updated_at_ms = $5 WHERE owner_key = $6 AND rev = $7`), [
|
|
532
398
|
this.enc(nextRules, "permission_rule.rules_json"),
|
|
533
399
|
this.enc(nextTombstones, "permission_rule.tombstones_json"),
|
|
@@ -545,12 +411,6 @@ class SqlPermissionRuleStore {
|
|
|
545
411
|
},
|
|
546
412
|
};
|
|
547
413
|
}
|
|
548
|
-
/**
|
|
549
|
-
* SQL 双方言的 {@link PermissionRuleStoreProvider}。
|
|
550
|
-
*
|
|
551
|
-
* `forPrincipal(undefined)` 恒解析成**零规则**店(core 硬条款:未鉴权任务解析到零规则而不是一只共享桶
|
|
552
|
-
* —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
|
|
553
|
-
*/
|
|
554
414
|
export class SqlPermissionRuleStoreProvider {
|
|
555
415
|
db;
|
|
556
416
|
now;
|
|
@@ -567,20 +427,10 @@ export class SqlPermissionRuleStoreProvider {
|
|
|
567
427
|
return new SqlPermissionRuleStore(this.db, { kind: "local-owner" }, this.now);
|
|
568
428
|
}
|
|
569
429
|
}
|
|
570
|
-
/** 未鉴权解析到的零规则店。**刻意不复用** core 的 `EMPTY_RULE_STORE`:那一份声明的 durability 是
|
|
571
|
-
* 实现细节,而这一格的语义是「这条腿没有身份 ⇒ 没有规则」,自持一份让它不随上游默认值漂。 */
|
|
572
430
|
const ZERO_RULE_STORE = {
|
|
573
431
|
list: async () => ({ rules: [], tombstones: [], rev: 0 }),
|
|
574
432
|
durability: "process-local",
|
|
575
433
|
};
|
|
576
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
577
|
-
// 审批记录店(core `RuleApprovalRecordStore` 的 SQL 形)
|
|
578
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
579
|
-
/**
|
|
580
|
-
* durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
|
|
581
|
-
* 只比 state 的两次并发重试会**都**认为自己看到了预期状态,各铸一个 dot、各写一次,后者静默覆盖前者的
|
|
582
|
-
* `redeemedDots`。那正是「一次同意变成两个 add、一条删掉的规则复活」的机制。
|
|
583
|
-
*/
|
|
584
434
|
export class SqlRuleApprovalRecordStore {
|
|
585
435
|
db;
|
|
586
436
|
now;
|
|
@@ -595,12 +445,13 @@ export class SqlRuleApprovalRecordStore {
|
|
|
595
445
|
return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
|
|
596
446
|
}
|
|
597
447
|
async get(id) {
|
|
598
|
-
const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
|
|
448
|
+
const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
|
|
599
449
|
const row = rows[0];
|
|
600
450
|
if (row === undefined)
|
|
601
451
|
return undefined;
|
|
602
452
|
const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
|
|
603
453
|
const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : parseColumn(RedeemedDotsSchema, row.redeemed_dots_json, "redeemed_dots_json", id);
|
|
454
|
+
const edited = row.edited_json === null || row.edited_json === undefined ? undefined : parseColumn(RuleEditedSchema, row.edited_json, "edited_json", id);
|
|
604
455
|
const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
|
|
605
456
|
const state = z.enum(["pending", "approved", "redeemed"]).safeParse(row.state);
|
|
606
457
|
if (!kind.success || !state.success) {
|
|
@@ -608,8 +459,6 @@ export class SqlRuleApprovalRecordStore {
|
|
|
608
459
|
}
|
|
609
460
|
const ownerKind = String(row.owner_kind);
|
|
610
461
|
const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
|
|
611
|
-
// 身份两族(design/182 §4.5):v1 记录只带 principal;local-owner 记录只带 owner。**绝不同时带两个**
|
|
612
|
-
// (core:两者不一致的记录是矛盾的、不可兑付的)。
|
|
613
462
|
const redeemedDots = dotsRaw === undefined ? undefined : Object.fromEntries(Object.entries(dotsRaw).map(([k, v]) => [Number(k), v]));
|
|
614
463
|
return {
|
|
615
464
|
id: String(row.record_id),
|
|
@@ -621,6 +470,8 @@ export class SqlRuleApprovalRecordStore {
|
|
|
621
470
|
rev: Number(row.rev ?? 0),
|
|
622
471
|
...(row.tool_call_id !== null && row.tool_call_id !== undefined ? { toolCallId: String(row.tool_call_id) } : {}),
|
|
623
472
|
...(row.bound_input_hash !== null && row.bound_input_hash !== undefined ? { boundInputHash: String(row.bound_input_hash) } : {}),
|
|
473
|
+
...(row.command !== null && row.command !== undefined ? { command: String(row.command) } : {}),
|
|
474
|
+
...(edited !== undefined ? { edited } : {}),
|
|
624
475
|
...(row.selected_candidate !== null && row.selected_candidate !== undefined ? { selectedCandidate: Number(row.selected_candidate) } : {}),
|
|
625
476
|
...(redeemedDots !== undefined ? { redeemedDots } : {}),
|
|
626
477
|
};
|
|
@@ -628,9 +479,9 @@ export class SqlRuleApprovalRecordStore {
|
|
|
628
479
|
async create(record) {
|
|
629
480
|
const nowMs = this.now();
|
|
630
481
|
const owner = record.owner ?? { kind: "principal", principal: record.principal ?? "" };
|
|
631
|
-
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso, created_at_ms, updated_at_ms)
|
|
632
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso, created_at_ms, updated_at_ms)
|
|
633
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`), [
|
|
482
|
+
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
|
|
483
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
|
|
484
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`), [
|
|
634
485
|
record.id,
|
|
635
486
|
buildRuleOwnerKey(owner),
|
|
636
487
|
owner.kind,
|
|
@@ -643,35 +494,28 @@ export class SqlRuleApprovalRecordStore {
|
|
|
643
494
|
record.redeemedDots === undefined ? null : this.enc(record.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
|
|
644
495
|
record.toolCallId ?? null,
|
|
645
496
|
record.boundInputHash ?? null,
|
|
497
|
+
record.command ?? null,
|
|
498
|
+
record.edited === undefined ? null : this.enc(record.edited, "permission_rule_approval.edited_json"),
|
|
646
499
|
record.createdAt,
|
|
647
500
|
nowMs,
|
|
648
501
|
nowMs,
|
|
649
502
|
]);
|
|
650
503
|
}
|
|
651
|
-
/**
|
|
652
|
-
* 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
|
|
653
|
-
*
|
|
654
|
-
* 用处只有一个:`prepareCcImport` 会在**返回预览之前**就把 pending 记录落盘,所以「预览超帽、这次导入
|
|
655
|
-
* 不做了」这条路会留下一条永远没人要的行。`WHERE state = 'pending'` 是硬的 —— 已确认/已兑付的记录是
|
|
656
|
-
* 一次真人同意的**审计事实**,任何路径都不许把它删掉(那条轴上的清理属于保留期策略,不是本方法)。
|
|
657
|
-
* 不是 core `RuleApprovalRecordStore` 的成员(那个接口只有 get/cas/create),故命名上与它区分。
|
|
658
|
-
*/
|
|
659
504
|
async discardPendingRecord(recordId) {
|
|
660
505
|
const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending'`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending'`), [recordId]);
|
|
661
506
|
return affected === 1;
|
|
662
507
|
}
|
|
663
|
-
/** compare-and-set on `rev`(core 硬条款)。`next.rev` 必须是 `expectRev + 1`;不是 ⇒ 响亮拒绝
|
|
664
|
-
* (一个不推进的 CAS 会让两次写互相覆盖而两边都以为自己赢了)。 */
|
|
665
508
|
async cas(id, expectRev, next) {
|
|
666
509
|
if (next.rev !== expectRev + 1) {
|
|
667
510
|
throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
|
|
668
511
|
}
|
|
669
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, selected_candidate = ?, redeemed_dots_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, selected_candidate = $4, redeemed_dots_json = $5,
|
|
512
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, selected_candidate = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, selected_candidate = $4, redeemed_dots_json = $5, edited_json = $6, updated_at_ms = $7 WHERE record_id = $8 AND rev = $9`), [
|
|
670
513
|
next.state,
|
|
671
514
|
next.rev,
|
|
672
515
|
this.enc(next.candidates, "permission_rule_approval.candidates_json"),
|
|
673
516
|
next.selectedCandidate ?? null,
|
|
674
517
|
next.redeemedDots === undefined ? null : this.enc(next.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
|
|
518
|
+
next.edited === undefined ? null : this.enc(next.edited, "permission_rule_approval.edited_json"),
|
|
675
519
|
this.now(),
|
|
676
520
|
id,
|
|
677
521
|
expectRev,
|
|
@@ -679,19 +523,7 @@ export class SqlRuleApprovalRecordStore {
|
|
|
679
523
|
return affected === 1;
|
|
680
524
|
}
|
|
681
525
|
}
|
|
682
|
-
/**
|
|
683
|
-
* [4232]③ 同族的 **GC 宽限**:`reapExpired` 只收「按墙钟至少已死一个完整 TTL」的票行。
|
|
684
|
-
*
|
|
685
|
-
* 为什么需要它:判定侧改单调锚之后,一张「按墙钟已过期、按单调钟还在窗内」的票是**可兑付**的;
|
|
686
|
-
* 若 GC 仍按 `expires_at_ms <= now` 收行,判定修好了而行先没了 —— 用户拿到的从 `expired` 变成 `unknown`,
|
|
687
|
-
* 缺陷只是换了个归因。宽限一个 TTL 覆盖住「铸票时钟慢了不到一个 TTL」这一族(含 [4232]③ 的真实构造)。
|
|
688
|
-
*
|
|
689
|
-
* 残余(如实登记,不假称已解):墙钟被拨慢**超过**一个 TTL 时,行仍可能在单调窗内被收。彻底解需要行上
|
|
690
|
-
* 存单调基准,而单调基准跨进程无意义(core [4235] 的判词)。补偿是现成的:prepare 廉价,重铸一张即可
|
|
691
|
-
* ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
|
|
692
|
-
*/
|
|
693
526
|
export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
|
|
694
|
-
/** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
|
|
695
527
|
export function buildRulePayloadHash(candidates) {
|
|
696
528
|
return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
|
|
697
529
|
}
|
|
@@ -723,31 +555,10 @@ export class SqlRuleImportTicketStore {
|
|
|
723
555
|
]);
|
|
724
556
|
return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
|
|
725
557
|
}
|
|
726
|
-
/**
|
|
727
|
-
* **一次性原子认领**(F1 ③)。四个条件全部写在 `WHERE` 里 —— ticket 身份、principal 绑定、未过期、
|
|
728
|
-
* 未认领 —— 于是并发双 redeem 由引擎的行锁裁决,恰一条 `affected === 1`。
|
|
729
|
-
*
|
|
730
|
-
* 🔴 **认领的 UPDATE 是这条方法的最后一次 I/O**(codex 交叉复审 round4 [high],验真后修)。
|
|
731
|
-
* 旧形是「先 UPDATE 认领、再 SELECT 取 approvalId/payloadHash」——那条 SELECT 失败时本方法**抛错**,
|
|
732
|
-
* 而调用方(`redeemImport`)此时还没进它的恢复 `try`,于是**没有任何人去放回认领**:票被永久烧掉,
|
|
733
|
-
* 重试收到不可重试的 404,人的确认凭空消失。改法不是加一层 catch,而是**把那次读挪到认领之前**:
|
|
734
|
-
* `approval_id` / `payload_hash` 自铸票起就是**不可变**列,先读与后读读到的是同一份字节。
|
|
735
|
-
*
|
|
736
|
-
* 🔴 **这仍然不是 read-then-write**(禁令的语义没有松动):**裁决**依旧只由那条条件 `UPDATE` 的
|
|
737
|
-
* `WHERE` 做出 —— 预读的三条否定项只用来**短路 + 归因**(把拒绝分成 unknown/wrong-principal/
|
|
738
|
-
* expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
|
|
739
|
-
* 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
|
|
740
|
-
*/
|
|
741
558
|
async consume(ticketId, principal, opts) {
|
|
742
|
-
// 🔴 [4232]③/[4235]③ 两只钟分家(**判定** vs **记账**),缺一不可:
|
|
743
|
-
// · `decisionNowMs` = 判定钟。缺席 ⇒ 墙钟(既有行为逐字不变);在场 ⇒ 调用方给的**单调锚投影**
|
|
744
|
-
// (铸票时刻的墙钟 + 自铸票起单调钟走过的量)。它只进那条 `expires_at_ms > ?` 的 WHERE。
|
|
745
|
-
// · `stampMs` = 记账钟,恒为真墙钟。写进 `consumed_at_ms` 的必须是「这件事在挂钟上什么时候发生」,
|
|
746
|
-
// 审计行不许被判定侧的投影污染(任务书自审第三镜:记账钟不动,只改判定钟)。
|
|
747
559
|
const stampMs = this.now();
|
|
748
560
|
const decisionNowMs = opts?.decisionNowMs ?? stampMs;
|
|
749
561
|
const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
|
|
750
|
-
// 预读:①取不可变的 approvalId/payloadHash(于是认领之后零 I/O);②给四类拒绝一个准确归因。
|
|
751
562
|
const row = await this.readRow(ticketId);
|
|
752
563
|
if (row === undefined)
|
|
753
564
|
return { ok: false, reason: "unknown" };
|
|
@@ -758,34 +569,11 @@ export class SqlRuleImportTicketStore {
|
|
|
758
569
|
if (row.expiresAtMs <= decisionNowMs)
|
|
759
570
|
return { ok: false, reason: "expired" };
|
|
760
571
|
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ? WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = $1 WHERE ticket_id = $2 AND owner_key = $3 AND consumed_at_ms IS NULL AND expires_at_ms > $4`), [stampMs, ticketId, ownerKey, decisionNowMs]);
|
|
761
|
-
// 认领成功 ⇒ 直接用预读那份不可变值返回,**不再发任何一条语句**(这正是本次修复的全部内容)。
|
|
762
572
|
if (affected === 1)
|
|
763
573
|
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
|
|
764
|
-
return { ok: false, reason: "consumed" };
|
|
574
|
+
return { ok: false, reason: "consumed" };
|
|
765
575
|
}
|
|
766
|
-
/**
|
|
767
|
-
* **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
|
|
768
|
-
*
|
|
769
|
-
* `consume` 是一次**认领**(claim),不是「这件事已经做完了」。认领之后还有三步会失败:读记录、
|
|
770
|
-
* 载荷比对、confirm+批量兑付。旧形把认领当终局 ⇒ 一次数据库抖动就让票**永久**烧掉,而调用方拿到的
|
|
771
|
-
* 是与「票不存在」同形的 404:重试无门、最终状态对客户端不可判。
|
|
772
|
-
*
|
|
773
|
-
* ⇒ 认领之后**只有裁定性拒绝**才留着消费位(载荷被篡改、记录不存在 —— 重试不改判);**不确定/瞬时**
|
|
774
|
-
* 的失败(store 抛错、CAS 冲突)把认领**放回去**,让属主重试。放回去不会打开双兑付的门:同一时刻
|
|
775
|
-
* 只有一个持有者(认领本身仍是单条条件 UPDATE),而兑付腿(`redeemRuleBatch`)按 core 的设计是
|
|
776
|
-
* **幂等**的(记录里存着每个候选已铸的 dot,重放只会重放同一个 dot)。
|
|
777
|
-
*
|
|
778
|
-
* 残留(如实登记,不假称已解):认领与释放之间**硬崩**会把票留在已认领态 —— 它随 TTL 自然消失,
|
|
779
|
-
* 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
|
|
780
|
-
* 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
|
|
781
|
-
*/
|
|
782
576
|
async release(ticketId, principal, mustRemainValidMs = 0, opts) {
|
|
783
|
-
// 🔴 codex 交叉复审 round6 [medium](验真后修):`expires_at_ms` 也进 WHERE,而且比的是
|
|
784
|
-
// **now + 通告的重试窗**,不是 now。理由:调用方拿本方法的返回值去决定要不要对客户端说
|
|
785
|
-
// 「稍后拿同一张票重试」——若这张票在那几秒里就会过期,那句话是**必然兑现不了**的承诺
|
|
786
|
-
// (客户端等完 Retry-After 回来只会收到 404,而且这一轮的确认已经丢了)。撑不过窗 ⇒ 如实回 false,
|
|
787
|
-
// 调用方于是按不可重试报(四类同形 404),不给一条注定走死的路。
|
|
788
|
-
// `decisionNowMs`(判定钟)与 `consume` 同源同义 —— 本语句只有判定,没有记账列可写。
|
|
789
577
|
const nowMs = opts?.decisionNowMs ?? this.now();
|
|
790
578
|
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NOT NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = $1 AND owner_key = $2 AND consumed_at_ms IS NOT NULL AND expires_at_ms > $3`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), nowMs + mustRemainValidMs]);
|
|
791
579
|
return affected === 1;
|
|
@@ -804,34 +592,7 @@ export class SqlRuleImportTicketStore {
|
|
|
804
592
|
};
|
|
805
593
|
}
|
|
806
594
|
}
|
|
807
|
-
/**
|
|
808
|
-
* 孤儿 pending 审批记录的保留期(A-010.17)。
|
|
809
|
-
*
|
|
810
|
-
* 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活,
|
|
811
|
-
* 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求),
|
|
812
|
-
* 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的
|
|
813
|
-
* `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能**
|
|
814
|
-
* 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。
|
|
815
|
-
*
|
|
816
|
-
* 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord`
|
|
817
|
-
* 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)——
|
|
818
|
-
* 保留期策略动不到它们,本腿一行都不碰。
|
|
819
|
-
*/
|
|
820
595
|
export const RULE_PENDING_APPROVAL_RETENTION_MS = 24 * 60 * 60_000;
|
|
821
|
-
/**
|
|
822
|
-
* 保留期腿**每轮**最多删多少行(codex R2 [high])。
|
|
823
|
-
*
|
|
824
|
-
* 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它
|
|
825
|
-
* 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)——
|
|
826
|
-
* 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里
|
|
827
|
-
* 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
|
|
828
|
-
* (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
|
|
829
|
-
*
|
|
830
|
-
* ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
|
|
831
|
-
* 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
|
|
832
|
-
* 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
|
|
833
|
-
* 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
|
|
834
|
-
*/
|
|
835
596
|
export const RULE_REAP_BATCH = 500;
|
|
836
597
|
export function createSqlPermissionRuleStores(db, now) {
|
|
837
598
|
const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
|
|
@@ -840,30 +601,12 @@ export function createSqlPermissionRuleStores(db, now) {
|
|
|
840
601
|
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
841
602
|
tickets: new SqlRuleImportTicketStore(db, now),
|
|
842
603
|
reapExpired: async (nowMs) => {
|
|
843
|
-
|
|
844
|
-
// (维护腿的既有姿势 —— 邻居 reaper 腿全是独立语句)。
|
|
845
|
-
//
|
|
846
|
-
// 🔴 **每 tick 有界**(codex 对抗复审 R2 [high],验真后修):第一版是两条**无界** DELETE。
|
|
847
|
-
// 首次开清扫(或一次长时间没跑的部署)会在**一个事务**里删掉整个积压 —— 一条能跑很久、锁很多行、
|
|
848
|
-
// 把 binlog/WAL 顶起来的语句;而调用点当时又没有重入守卫,一旦耗时越过 tick 间隔,下一轮就叠上来。
|
|
849
|
-
// 收成每轮 {@link RULE_REAP_BATCH} 行:积压按轮**渐进**清空(每轮都是完整语义,不留半干净状态),
|
|
850
|
-
// 单条语句的最坏时长与库大小脱钩。调用侧另配了 in-flight 守卫(`boot/reapers.ts`)。
|
|
851
|
-
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`,
|
|
852
|
-
// PG 的 DELETE 没有 LIMIT ⇒ 用 ctid 子查询限行(PG 侧的标准写法;`ctid` 是物理行号,
|
|
853
|
-
// 子查询里带 LIMIT 才是被支持的那一形)。两方言的**语义**相同:本轮最多删这么多行。
|
|
854
|
-
`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= $1 LIMIT ${RULE_REAP_BATCH})`),
|
|
855
|
-
// [4232]③:减去 {@link RULE_IMPORT_TICKET_GC_GRACE_MS} —— 判定侧走单调锚之后,
|
|
856
|
-
// 「按墙钟已过期、按单调钟还在窗内」的票是可兑付的,GC 不能抢在它前面把行收走。
|
|
857
|
-
[nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
|
|
604
|
+
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
|
|
858
605
|
const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
|
|
859
606
|
return deadTickets.affected + orphanPending.affected;
|
|
860
607
|
},
|
|
861
|
-
// 两个方言逐字同形(`COUNT(*)` 无方言差),所以刻意**不**走 `q(tidb, pg)` 的双串姿势 —— 那会造出
|
|
862
|
-
// 两份可以各自漂的同一句 SQL。参数空数组:本语句没有绑定位。
|
|
863
608
|
countBuckets: async () => {
|
|
864
609
|
const { rows } = await db.query(`SELECT COUNT(*) AS n FROM ${PERMISSION_RULE_TABLE}`, []);
|
|
865
|
-
// PG 的 `COUNT(*)` 走 bigint ⇒ 驱动交回**字符串**;mysql2 交回 number。`Number()` 是两侧共同的收口,
|
|
866
|
-
// 读不出数(列缺席/NaN)⇒ 响亮抛,绝不静默当 0(0 会被审计读成「确认没有休眠行」= 假结论)。
|
|
867
610
|
const n = Number(rows[0]?.["n"] ?? Number.NaN);
|
|
868
611
|
if (!Number.isFinite(n))
|
|
869
612
|
throw new Error(`${PERMISSION_RULE_TABLE} bucket count came back unreadable (${String(rows[0]?.["n"])})`);
|