@crediolabs/policy-synth 0.1.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.
Files changed (85) hide show
  1. package/dist/adapters/oz/adapter.d.ts +20 -0
  2. package/dist/adapters/oz/adapter.js +282 -0
  3. package/dist/adapters/oz/index.d.ts +1 -0
  4. package/dist/adapters/oz/index.js +2 -0
  5. package/dist/errors.d.ts +37 -0
  6. package/dist/errors.js +2 -0
  7. package/dist/index.d.ts +9 -0
  8. package/dist/index.js +9 -0
  9. package/dist/ir/index.d.ts +1 -0
  10. package/dist/ir/index.js +2 -0
  11. package/dist/ir/types.d.ts +97 -0
  12. package/dist/ir/types.js +11 -0
  13. package/dist/mandate/index.d.ts +2 -0
  14. package/dist/mandate/index.js +2 -0
  15. package/dist/mandate/to-ir.d.ts +3 -0
  16. package/dist/mandate/to-ir.js +60 -0
  17. package/dist/mandate/types.d.ts +20 -0
  18. package/dist/mandate/types.js +8 -0
  19. package/dist/record/decode.d.ts +76 -0
  20. package/dist/record/decode.js +372 -0
  21. package/dist/record/freshness.d.ts +17 -0
  22. package/dist/record/freshness.js +50 -0
  23. package/dist/record/index.d.ts +21 -0
  24. package/dist/record/index.js +163 -0
  25. package/dist/record/movements.d.ts +20 -0
  26. package/dist/record/movements.js +187 -0
  27. package/dist/record/rpc.d.ts +22 -0
  28. package/dist/record/rpc.js +70 -0
  29. package/dist/record/validate.d.ts +22 -0
  30. package/dist/record/validate.js +60 -0
  31. package/dist/registry/identify.d.ts +11 -0
  32. package/dist/registry/identify.js +84 -0
  33. package/dist/registry/index.d.ts +3 -0
  34. package/dist/registry/index.js +4 -0
  35. package/dist/registry/known-addresses.d.ts +16 -0
  36. package/dist/registry/known-addresses.js +49 -0
  37. package/dist/registry/protocols.d.ts +38 -0
  38. package/dist/registry/protocols.js +149 -0
  39. package/dist/seams/index.d.ts +1 -0
  40. package/dist/seams/index.js +2 -0
  41. package/dist/seams/types.d.ts +66 -0
  42. package/dist/seams/types.js +11 -0
  43. package/dist/synth/compose-from-recording.d.ts +36 -0
  44. package/dist/synth/compose-from-recording.js +162 -0
  45. package/dist/synth/index.d.ts +5 -0
  46. package/dist/synth/index.js +6 -0
  47. package/dist/synth/lower.d.ts +23 -0
  48. package/dist/synth/lower.js +116 -0
  49. package/dist/synth/scope.d.ts +26 -0
  50. package/dist/synth/scope.js +77 -0
  51. package/dist/synth/synthesize-from-mandate.d.ts +5 -0
  52. package/dist/synth/synthesize-from-mandate.js +34 -0
  53. package/dist/synth/synthesize-from-recording.d.ts +17 -0
  54. package/dist/synth/synthesize-from-recording.js +178 -0
  55. package/dist/types.d.ts +249 -0
  56. package/dist/types.js +35 -0
  57. package/package.json +37 -0
  58. package/src/adapters/oz/adapter.ts +363 -0
  59. package/src/adapters/oz/index.ts +9 -0
  60. package/src/errors.ts +79 -0
  61. package/src/index.ts +9 -0
  62. package/src/ir/index.ts +13 -0
  63. package/src/ir/types.ts +94 -0
  64. package/src/mandate/index.ts +4 -0
  65. package/src/mandate/to-ir.ts +71 -0
  66. package/src/mandate/types.ts +21 -0
  67. package/src/record/decode.ts +500 -0
  68. package/src/record/freshness.ts +63 -0
  69. package/src/record/index.ts +224 -0
  70. package/src/record/movements.ts +188 -0
  71. package/src/record/rpc.ts +88 -0
  72. package/src/record/validate.ts +75 -0
  73. package/src/registry/identify.ts +99 -0
  74. package/src/registry/index.ts +24 -0
  75. package/src/registry/known-addresses.ts +71 -0
  76. package/src/registry/protocols.ts +176 -0
  77. package/src/seams/index.ts +11 -0
  78. package/src/seams/types.ts +81 -0
  79. package/src/synth/compose-from-recording.ts +226 -0
  80. package/src/synth/index.ts +19 -0
  81. package/src/synth/lower.ts +144 -0
  82. package/src/synth/scope.ts +108 -0
  83. package/src/synth/synthesize-from-mandate.ts +46 -0
  84. package/src/synth/synthesize-from-recording.ts +226 -0
  85. package/src/types.ts +218 -0
@@ -0,0 +1,144 @@
1
+ // src/synth/lower.ts - lower a RecordedTransaction to IntentFacts.
2
+ //
3
+ // Reads the top-level invocations only (the single authorized call that
4
+ // `Policy::enforce` receives; v1 grammar does not walk sub-invocations) and
5
+ // the recorded token movements. Produces the deterministic facts the synth
6
+ // reasons over: distinct call targets, functions per target, cumulative spend
7
+ // per token (outgoing movements from the recorded source account only),
8
+ // authorising signers, an optional shared-router marker when multiple top-level
9
+ // invocations hit the same contract, and the per-contract hop-path lists for
10
+ // router calls (SoroSwap `path`).
11
+
12
+ import type { ContractInvocation, RecordedTransaction, ScVal, TokenMovement } from '../types.ts'
13
+
14
+ /** Facts the recording-path synth reasons over. */
15
+ export interface IntentFacts {
16
+ callTargets: string[]
17
+ functionsByContract: Record<string, string[]>
18
+ /** Aggregate outgoing spend per token (i128-safe decimal string). A single
19
+ * token entry is the Path-A prerequisite for `spending_limit`. Movements
20
+ * with from != sourceAccount are NOT counted as "spend" by this synth
21
+ * (incoming yield, refund, etc.). */
22
+ spendByToken: Record<string, string>
23
+ signers: string[]
24
+ /** Set when more than one top-level invocation shares a target contract
25
+ * (a router); decideScope uses this to scope by the router instead of
26
+ * returning SCOPE_UNRESOLVED. */
27
+ sharedRouter?: string
28
+ /** Router hop paths, keyed by target contract. SoroSwap exposes its `path`
29
+ * arg directly on the top-level call; this is the input the compose step
30
+ * passes to a per-arg `in` condition (flagged Path-B by the OZ adapter). */
31
+ allowedPaths?: Record<string, string[][]>
32
+ }
33
+
34
+ /** Lower a recorded transaction to the canonical IntentFacts. Pure (no
35
+ * randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
36
+ export function lower(tx: RecordedTransaction): IntentFacts {
37
+ const invocations = tx.invocations
38
+ const callTargets = uniqueOrdered(invocations.map((i) => i.contract))
39
+ const functionsByContract = groupFunctionsByContract(invocations)
40
+ const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, tx.sourceAccount)
41
+ const allowedPaths = extractPathsByContract(invocations)
42
+ const sharedRouter = inferSharedRouter(invocations)
43
+
44
+ const facts: IntentFacts = {
45
+ callTargets,
46
+ functionsByContract,
47
+ spendByToken,
48
+ signers: [...tx.signers],
49
+ }
50
+ if (sharedRouter) facts.sharedRouter = sharedRouter
51
+ if (Object.keys(allowedPaths).length > 0) facts.allowedPaths = allowedPaths
52
+ return facts
53
+ }
54
+
55
+ /** Preserve first-seen ordering, drop duplicates. */
56
+ function uniqueOrdered(items: string[]): string[] {
57
+ const seen = new Set<string>()
58
+ const out: string[] = []
59
+ for (const item of items) {
60
+ if (seen.has(item)) continue
61
+ seen.add(item)
62
+ out.push(item)
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** Group the function names invoked per top-level contract. Order preserved
68
+ * per contract in first-seen order. */
69
+ function groupFunctionsByContract(invocations: ContractInvocation[]): Record<string, string[]> {
70
+ const out: Record<string, string[]> = {}
71
+ for (const inv of invocations) {
72
+ const existing = out[inv.contract] ?? []
73
+ if (!existing.includes(inv.fn)) existing.push(inv.fn)
74
+ out[inv.contract] = existing
75
+ }
76
+ return out
77
+ }
78
+
79
+ /** Sum outgoing TokenMovement amounts per token, where `from === source`.
80
+ * BigInt throughout; never lossy. Movements whose `from` does not match the
81
+ * recorded source account are NOT counted (incoming yield, refund, etc.). */
82
+ function aggregateOutgoingSpend(
83
+ movements: TokenMovement[],
84
+ sourceAccount: string
85
+ ): Record<string, string> {
86
+ const totals = new Map<string, bigint>()
87
+ for (const m of movements) {
88
+ if (m.from !== sourceAccount) continue
89
+ const current = totals.get(m.token) ?? 0n
90
+ totals.set(m.token, current + BigInt(m.amount))
91
+ }
92
+ const out: Record<string, string> = {}
93
+ for (const [token, total] of totals) {
94
+ out[token] = total.toString()
95
+ }
96
+ return out
97
+ }
98
+
99
+ /** Walk the top-level invocations looking for a `vec` arg whose elements are
100
+ * addresses (the SoroSwap `path` shape). Only the FIRST arg in each
101
+ * invocation is examined in v1 - the recorder emits the top-level args
102
+ * in declaration order and the only contract-call convention we bind by is
103
+ * the router's first vec-arg path. */
104
+ function extractPathsByContract(invocations: ContractInvocation[]): Record<string, string[][]> {
105
+ const out: Record<string, string[][]> = {}
106
+ for (const inv of invocations) {
107
+ const path = findAddressVec(inv.args)
108
+ if (path) {
109
+ const existing = out[inv.contract] ?? []
110
+ existing.push(path)
111
+ out[inv.contract] = existing
112
+ }
113
+ }
114
+ return out
115
+ }
116
+
117
+ function findAddressVec(args: ScVal[]): string[] | null {
118
+ for (const arg of args) {
119
+ if (arg.type === 'vec') {
120
+ const addresses = arg.value.filter(
121
+ (v): v is Extract<ScVal, { type: 'address' }> => v.type === 'address'
122
+ )
123
+ if (addresses.length === arg.value.length && addresses.length > 0) {
124
+ return addresses.map((a) => a.value)
125
+ }
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ /** When multiple top-level invocations share the same target contract, that
132
+ * contract is the shared router. Single-call transactions return undefined
133
+ * (the default flow doesn't need the marker). */
134
+ function inferSharedRouter(invocations: ContractInvocation[]): string | undefined {
135
+ if (invocations.length < 2) return undefined
136
+ const counts = new Map<string, number>()
137
+ for (const inv of invocations) {
138
+ counts.set(inv.contract, (counts.get(inv.contract) ?? 0) + 1)
139
+ }
140
+ for (const [contract, count] of counts) {
141
+ if (count >= 2) return contract
142
+ }
143
+ return undefined
144
+ }
@@ -0,0 +1,108 @@
1
+ // src/synth/scope.ts - decide the OZ context-rule scope from IntentFacts.
2
+ //
3
+ // Pure decision function. Three branches:
4
+ // - single call target -> CallContract(target)
5
+ // - multiple targets w/ sharedRouter -> CallContract(sharedRouter)
6
+ // - multiple unrelated targets -> SCOPE_UNRESOLVED ToolError
7
+ //
8
+ // `decideScope` also surfaces the `DURATION_UNSPECIFIED` ambiguity when the
9
+ // caller has NOT supplied a validUntilLedger - the synth never fabricates a
10
+ // window. The orchestrator is responsible for assembling any
11
+ // `AmbiguityPrompt[]` from this and other decision points into the
12
+ // `ProposedPolicy.ambiguities` field.
13
+
14
+ import type { ToolError, ToolResponse } from '../errors.ts'
15
+ import type { AmbiguityPrompt, ContextRuleDraft, Network } from '../types.ts'
16
+ import type { IntentFacts } from './lower.ts'
17
+
18
+ export type ScopeDecision =
19
+ | { kind: 'call_contract'; contract: string; ambiguities: AmbiguityPrompt[] }
20
+ | { kind: 'default'; ambiguities: AmbiguityPrompt[] }
21
+
22
+ /** Options for the scope decision. `validUntilLedger` may be supplied via the
23
+ * synth opts; when absent the DURATION_UNSPECIFIED ambiguity is surfaced so
24
+ * the LLM can ask the user rather than the synth fabricating a window. */
25
+ export interface DecideScopeOptions {
26
+ network: Network
27
+ validUntilLedger?: number
28
+ }
29
+
30
+ /** Decide the OZ context-rule scope. Returns `ScopeDecision` on success or a
31
+ * `ToolError` with code `SCOPE_UNRESOLVED` when the recorded tx spans
32
+ * multiple unrelated call targets. */
33
+ export function decideScope(
34
+ facts: IntentFacts,
35
+ opts: DecideScopeOptions
36
+ ): ToolResponse<ScopeDecision> {
37
+ const ambiguities: AmbiguityPrompt[] = []
38
+ if (opts.validUntilLedger === undefined) {
39
+ ambiguities.push({
40
+ code: 'DURATION_UNSPECIFIED',
41
+ question:
42
+ 'No expiry supplied for this policy. What `validUntilLedger` should the policy carry?',
43
+ })
44
+ }
45
+
46
+ // Single target -> scope directly.
47
+ if (facts.callTargets.length === 1) {
48
+ const contract = facts.callTargets[0]
49
+ if (contract === undefined) {
50
+ return {
51
+ ok: false,
52
+ error: scopeUnresolvedError(facts, 'no contract address resolvable from the recording'),
53
+ }
54
+ }
55
+ return {
56
+ ok: true,
57
+ data: { kind: 'call_contract', contract, ambiguities },
58
+ }
59
+ }
60
+
61
+ // Multiple targets with a shared router -> scope by the router.
62
+ if (facts.sharedRouter) {
63
+ return {
64
+ ok: true,
65
+ data: {
66
+ kind: 'call_contract',
67
+ contract: facts.sharedRouter,
68
+ ambiguities,
69
+ },
70
+ }
71
+ }
72
+
73
+ // Multiple unrelated targets -> fail closed.
74
+ return {
75
+ ok: false,
76
+ error: scopeUnresolvedError(
77
+ facts,
78
+ `multiple unrelated call targets: ${facts.callTargets.join(', ')}`
79
+ ),
80
+ }
81
+ }
82
+
83
+ /** Helper to project a `ScopeDecision` into the OZ `ContextRuleDraft`
84
+ * contextRuleType shape consumed by `composeFromRecording` and the OZ
85
+ * adapter. */
86
+ export function scopeToContextRuleType(scope: ScopeDecision): ContextRuleDraft['contextRuleType'] {
87
+ return scope.kind === 'call_contract'
88
+ ? { kind: 'call_contract', contract: scope.contract }
89
+ : { kind: 'default' }
90
+ }
91
+
92
+ function scopeUnresolvedError(facts: IntentFacts, message: string): ToolError {
93
+ return {
94
+ code: 'SCOPE_UNRESOLVED',
95
+ message,
96
+ severity: 'error',
97
+ retryable: false,
98
+ details: { callTargets: facts.callTargets, sharedRouter: facts.sharedRouter ?? null },
99
+ remediation: {
100
+ userQuestion: {
101
+ code: 'MULTIPLE_UNRELATED_TARGETS',
102
+ question: `Recording spans ${facts.callTargets.length} call targets (${facts.callTargets.join(
103
+ ', '
104
+ )}). Which contract (or shared router) should this policy scope to?`,
105
+ },
106
+ },
107
+ }
108
+ }
@@ -0,0 +1,46 @@
1
+ // src/synth/synthesize-from-mandate.ts - the deterministic Mandate front-end.
2
+ //
3
+ // synthesizeFromMandate is the clean end-to-end demo path: a declarative
4
+ // MandateSpec is lowered deterministically to a PolicyIR and compiled by the OZ
5
+ // adapter to a ProposedPolicy. No decoding, no inference, so parseConfidence is
6
+ // the full/not-applicable value. Constructs the OZ built-in primitives cannot
7
+ // express (compile's `uncovered`) are surfaced in `ProposedPolicy.warnings` as
8
+ // Path-B markers rather than failing the call - the covered primitives still
9
+ // install; the uncovered ones are reported.
10
+
11
+ import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
12
+ import { createOzAdapter } from '../adapters/oz/adapter.ts'
13
+ import type { ToolResponse } from '../errors.ts'
14
+ import { mandateToPolicyIR } from '../mandate/to-ir.ts'
15
+ import type { MandateSpec } from '../mandate/types.ts'
16
+ import type { ProposedPolicy } from '../types.ts'
17
+
18
+ const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): '
19
+
20
+ export function synthesizeFromMandate(
21
+ spec: MandateSpec,
22
+ ozConfig: OzAdapterConfig
23
+ ): ToolResponse<ProposedPolicy> {
24
+ const ir = mandateToPolicyIR(spec)
25
+ const adapter = createOzAdapter(ozConfig)
26
+ const result = adapter.compile(ir)
27
+
28
+ if (!result.proposed) {
29
+ return {
30
+ ok: false,
31
+ error: {
32
+ code: 'SYNTHESIS_ERROR',
33
+ message: `mandate lowered to no installable OZ policy: ${result.uncovered.join('; ')}`,
34
+ severity: 'error',
35
+ retryable: false,
36
+ details: { uncovered: result.uncovered },
37
+ },
38
+ }
39
+ }
40
+
41
+ const proposed: ProposedPolicy = {
42
+ ...result.proposed,
43
+ warnings: result.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
44
+ }
45
+ return { ok: true, data: proposed }
46
+ }
@@ -0,0 +1,226 @@
1
+ // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
2
+ //
3
+ // `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
4
+ // bounded policy from a `RecordedTransaction` via the same `PolicyIR` +
5
+ // `createOzAdapter` pair used by the deterministic Mandate path. The flow:
6
+ //
7
+ // 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
8
+ // the caller may relax via `confidenceOverride.threshold`).
9
+ // 2. `lower(tx)` -> IntentFacts.
10
+ // 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
11
+ // 4. `composeFromRecording(facts, scope, opts)` -> PolicyIR + ambiguities.
12
+ // 5. `ozAdapter.compile(ir)` -> CompileResult.
13
+ // 6. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
14
+ // (the OZ `uncovered` Path-B markers, prefixed), and ambiguities
15
+ // (DURATION_UNSPECIFIED etc.).
16
+ //
17
+ // Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
18
+ // No randomness, no clock, no globals.
19
+
20
+ import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
21
+ import { createOzAdapter } from '../adapters/oz/adapter.ts'
22
+ import type { ToolError, ToolResponse } from '../errors.ts'
23
+ import {
24
+ type Network,
25
+ type ProposedPolicy,
26
+ type RecordedTransaction,
27
+ SOROBAN_LIMITS,
28
+ } from '../types.ts'
29
+ import {
30
+ type ComposeOptions,
31
+ type ComposeUserResponses,
32
+ composeFromRecording,
33
+ } from './compose-from-recording.ts'
34
+ import { lower } from './lower.ts'
35
+ import { decideScope, type ScopeDecision } from './scope.ts'
36
+
37
+ const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): '
38
+
39
+ /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
40
+ * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
41
+ * limitAmount, invocationLimit). `confidenceOverride` relaxes the recorder's
42
+ * gate. */
43
+ export interface SynthesizeFromRecordingOptions {
44
+ network: Network
45
+ userResponses?: ComposeUserResponses
46
+ confidenceOverride?: { threshold: number }
47
+ }
48
+
49
+ /** Synthesize a ProposedPolicy from a recorded transaction. */
50
+ export function synthesizeFromRecording(
51
+ tx: RecordedTransaction,
52
+ opts: SynthesizeFromRecordingOptions,
53
+ ozConfig: OzAdapterConfig
54
+ ): ToolResponse<ProposedPolicy> {
55
+ // 0. validate inputs (fail closed - never synthesize from garbage).
56
+ const invalid = validateOptions(opts)
57
+ if (invalid) return { ok: false, error: invalid }
58
+
59
+ // 1. parseConfidence gate.
60
+ const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed
61
+ if (tx.parseConfidence.overall < threshold) {
62
+ return {
63
+ ok: false,
64
+ error: {
65
+ code: 'RECORDING_VALIDATION_FAILED',
66
+ message: `parseConfidence ${tx.parseConfidence.overall} < threshold ${threshold}`,
67
+ severity: 'error',
68
+ retryable: false,
69
+ details: tx.parseConfidence,
70
+ },
71
+ }
72
+ }
73
+
74
+ // Bound the recording size fail-closed (defense-in-depth for direct callers;
75
+ // the MCP schema caps this too).
76
+ if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
77
+ return {
78
+ ok: false,
79
+ error: {
80
+ code: 'RECORDING_VALIDATION_FAILED',
81
+ message: `recorded transaction has ${tx.invocations.length} invocations, exceeding the cap of ${SOROBAN_LIMITS.maxInvocations}`,
82
+ severity: 'error',
83
+ retryable: false,
84
+ },
85
+ }
86
+ }
87
+
88
+ // 2. lower.
89
+ const facts = lower(tx)
90
+
91
+ // 3. decideScope.
92
+ const scopeRes = decideScope(facts, {
93
+ network: opts.network,
94
+ ...(opts.userResponses?.validUntilLedger !== undefined
95
+ ? { validUntilLedger: opts.userResponses.validUntilLedger }
96
+ : {}),
97
+ })
98
+ if (!scopeRes.ok) return scopeRes
99
+ const scope: ScopeDecision = scopeRes.data
100
+ if (scope.kind !== 'call_contract') {
101
+ // A default scope on the recording-path is not currently a Path-A flow;
102
+ // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
103
+ // over-broad default-scoped policy.
104
+ return {
105
+ ok: false,
106
+ error: {
107
+ code: 'SYNTHESIS_ERROR',
108
+ message: 'recording path requires a scoped contract; no default-scoped policies emitted',
109
+ severity: 'error',
110
+ retryable: false,
111
+ },
112
+ }
113
+ }
114
+
115
+ // 4. compose.
116
+ const topLevel = tx.invocations[0] ?? null
117
+ const composeOpts: ComposeOptions = {
118
+ network: opts.network,
119
+ ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
120
+ }
121
+ const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
122
+
123
+ // 5. OZ compile.
124
+ const adapter = createOzAdapter(ozConfig)
125
+ const compileRes = adapter.compile(composed.ir)
126
+
127
+ if (!compileRes.proposed) {
128
+ return {
129
+ ok: false,
130
+ error: {
131
+ code: 'SYNTHESIS_ERROR',
132
+ message: `recording lowered to no installable OZ policy: ${compileRes.uncovered.join('; ')}`,
133
+ severity: 'error',
134
+ retryable: false,
135
+ details: { uncovered: compileRes.uncovered },
136
+ },
137
+ }
138
+ }
139
+
140
+ // 6. assemble ProposedPolicy.
141
+ const proposed: ProposedPolicy = {
142
+ ...compileRes.proposed,
143
+ parseConfidence: { ...tx.parseConfidence },
144
+ warnings: [
145
+ ...compileRes.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
146
+ ...composed.warnings.map((w) => `${PATH_B_PREFIX}${w}`),
147
+ ],
148
+ ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
149
+ }
150
+ return { ok: true, data: proposed }
151
+ }
152
+
153
+ /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
154
+ * validUntilLedger / invocationLimit must be positive integers; limitAmount a
155
+ * positive i128 decimal string; network mainnet|testnet. */
156
+ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null {
157
+ if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
158
+ return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`)
159
+ }
160
+ // A confidenceOverride outside [0, 1] would disable the recorder gate (a
161
+ // negative threshold can never be exceeded), so reject it fail-closed.
162
+ const co = opts.confidenceOverride?.threshold
163
+ if (co !== undefined && (!Number.isFinite(co) || co < 0 || co > 1)) {
164
+ return synthesisError(
165
+ `confidenceOverride.threshold must be a finite number within [0, 1], got: ${co}`
166
+ )
167
+ }
168
+ const ur = opts.userResponses
169
+ if (ur) {
170
+ if (ur.windowSeconds !== undefined && !isPositiveInt(ur.windowSeconds)) {
171
+ return synthesisError(`windowSeconds must be a positive integer, got: ${ur.windowSeconds}`)
172
+ }
173
+ if (
174
+ ur.validUntilLedger !== undefined &&
175
+ (!isPositiveInt(ur.validUntilLedger) || ur.validUntilLedger > SOROBAN_LIMITS.u32Max)
176
+ ) {
177
+ return synthesisError(
178
+ `validUntilLedger must be a positive u32 ledger sequence (<= ${SOROBAN_LIMITS.u32Max}), got: ${ur.validUntilLedger}`
179
+ )
180
+ }
181
+ if (ur.invocationLimit !== undefined && !isPositiveInt(ur.invocationLimit)) {
182
+ return synthesisError(
183
+ `invocationLimit must be a positive integer, got: ${ur.invocationLimit}`
184
+ )
185
+ }
186
+ if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
187
+ return synthesisError(
188
+ `limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`
189
+ )
190
+ }
191
+ }
192
+ return null
193
+ }
194
+
195
+ function isPositiveInt(n: number): boolean {
196
+ return Number.isInteger(n) && n > 0
197
+ }
198
+
199
+ /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
200
+ function isPositiveI128(s: string): boolean {
201
+ if (!/^[0-9]+$/.test(s)) return false
202
+ try {
203
+ return BigInt(s) > 0n
204
+ } catch {
205
+ return false
206
+ }
207
+ }
208
+
209
+ function synthesisError(message: string): ToolError {
210
+ return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false }
211
+ }
212
+
213
+ function mergeAmbiguities(
214
+ ...lists: ProposedPolicy['ambiguities'][]
215
+ ): ProposedPolicy['ambiguities'] {
216
+ const out: ProposedPolicy['ambiguities'] = []
217
+ const seen = new Set<string>()
218
+ for (const list of lists) {
219
+ for (const a of list) {
220
+ if (seen.has(a.code)) continue
221
+ seen.add(a.code)
222
+ out.push(a)
223
+ }
224
+ }
225
+ return out
226
+ }