@crediolabs/policy-synth 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +193 -5
  2. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  3. package/dist/adapters/interpreter/adapter.js +522 -0
  4. package/dist/adapters/interpreter/index.d.ts +1 -0
  5. package/dist/adapters/interpreter/index.js +2 -0
  6. package/dist/adapters/oz/adapter.js +20 -18
  7. package/dist/codegen/compile-gate.d.ts +33 -0
  8. package/dist/codegen/compile-gate.js +119 -0
  9. package/dist/codegen/index.d.ts +2 -0
  10. package/dist/codegen/index.js +8 -0
  11. package/dist/codegen/template.d.ts +18 -0
  12. package/dist/codegen/template.js +131 -0
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/ir/types.d.ts +13 -2
  17. package/dist/mandate/to-ir.js +4 -4
  18. package/dist/mandate/types.d.ts +1 -1
  19. package/dist/predicate/encode.d.ts +10 -0
  20. package/dist/predicate/encode.js +249 -0
  21. package/dist/predicate/index.d.ts +1 -0
  22. package/dist/predicate/index.js +2 -0
  23. package/dist/review-card/builder.d.ts +14 -0
  24. package/dist/review-card/builder.js +261 -0
  25. package/dist/review-card/conflict.d.ts +40 -0
  26. package/dist/review-card/conflict.js +111 -0
  27. package/dist/review-card/cross-check.d.ts +11 -0
  28. package/dist/review-card/cross-check.js +148 -0
  29. package/dist/review-card/index.d.ts +3 -0
  30. package/dist/review-card/index.js +4 -0
  31. package/dist/seams/types.d.ts +1 -1
  32. package/dist/synth/compose-from-recording.d.ts +35 -7
  33. package/dist/synth/compose-from-recording.js +231 -40
  34. package/dist/synth/deny-cases.d.ts +12 -0
  35. package/dist/synth/deny-cases.js +361 -0
  36. package/dist/synth/evaluate.d.ts +39 -0
  37. package/dist/synth/evaluate.js +425 -0
  38. package/dist/synth/harness.d.ts +16 -0
  39. package/dist/synth/harness.js +26 -0
  40. package/dist/synth/index.d.ts +4 -0
  41. package/dist/synth/index.js +4 -0
  42. package/dist/synth/lower.d.ts +2 -2
  43. package/dist/synth/minimize.d.ts +4 -0
  44. package/dist/synth/minimize.js +38 -0
  45. package/dist/synth/predicate-literals.d.ts +5 -0
  46. package/dist/synth/predicate-literals.js +25 -0
  47. package/dist/synth/synthesize-from-mandate.js +5 -5
  48. package/dist/synth/synthesize-from-recording.d.ts +32 -3
  49. package/dist/synth/synthesize-from-recording.js +491 -17
  50. package/dist/types.d.ts +23 -1
  51. package/dist/verify/envelope.d.ts +15 -0
  52. package/dist/verify/envelope.js +22 -0
  53. package/dist/verify/index.d.ts +3 -0
  54. package/dist/verify/index.js +3 -0
  55. package/dist/verify/simulate.d.ts +31 -0
  56. package/dist/verify/simulate.js +242 -0
  57. package/dist/verify/verify.d.ts +21 -0
  58. package/dist/verify/verify.js +173 -0
  59. package/package.json +1 -1
  60. package/src/adapters/interpreter/adapter.ts +642 -0
  61. package/src/adapters/interpreter/index.ts +8 -0
  62. package/src/adapters/oz/adapter.ts +20 -18
  63. package/src/codegen/compile-gate.ts +162 -0
  64. package/src/codegen/index.ts +17 -0
  65. package/src/codegen/template.ts +148 -0
  66. package/src/errors.ts +2 -0
  67. package/src/index.ts +2 -0
  68. package/src/ir/types.ts +9 -2
  69. package/src/mandate/to-ir.ts +4 -4
  70. package/src/mandate/types.ts +1 -1
  71. package/src/predicate/encode.ts +307 -0
  72. package/src/predicate/index.ts +3 -0
  73. package/src/review-card/builder.ts +303 -0
  74. package/src/review-card/conflict.ts +143 -0
  75. package/src/review-card/cross-check.ts +158 -0
  76. package/src/review-card/index.ts +12 -0
  77. package/src/seams/types.ts +1 -1
  78. package/src/synth/compose-from-recording.ts +283 -49
  79. package/src/synth/deny-cases.ts +447 -0
  80. package/src/synth/evaluate.ts +493 -0
  81. package/src/synth/harness.ts +40 -0
  82. package/src/synth/index.ts +12 -0
  83. package/src/synth/lower.ts +2 -2
  84. package/src/synth/minimize.ts +44 -0
  85. package/src/synth/predicate-literals.ts +27 -0
  86. package/src/synth/synthesize-from-mandate.ts +5 -5
  87. package/src/synth/synthesize-from-recording.ts +575 -18
  88. package/src/types.ts +19 -2
  89. package/src/verify/envelope.ts +28 -0
  90. package/src/verify/index.ts +5 -0
  91. package/src/verify/simulate.ts +292 -0
  92. package/src/verify/verify.ts +224 -0
@@ -0,0 +1,143 @@
1
+ // src/review-card/conflict.ts - the ConflictAnnotation 4-kind enum + the
2
+ // `classifyConflict` classifier.
3
+ //
4
+ // When a user installs a new policy alongside an existing rule set, the
5
+ // review card surfaces the relationship between the new rule and each
6
+ // existing rule as a structured annotation. A boolean (conflict / no
7
+ // conflict) is wrong: four qualitatively different shapes are possible, and
8
+ // each one tells the user something different about what would happen if
9
+ // both rules installed together.
10
+ //
11
+ // - subsume new rule's scope is a SUPERSET of an existing
12
+ // rule's. The new rule is broader; installing it on
13
+ // top of the existing rule weakens the existing
14
+ // restriction. The existing rule becomes a no-op.
15
+ // - disjoint new rule and existing rule are unrelated: their
16
+ // scopes do not overlap. Safe to install both.
17
+ // - counter_permissive new rule and existing rule allow CONFLICTING
18
+ // things at the same scope (one caps lower than the
19
+ // other on the same axis). The stricter one wins
20
+ // at evaluate; the looser is dead weight.
21
+ // - window_divergent new rule and existing rule have the same scope
22
+ // and the same per-window limit but DIFFERENT
23
+ // rolling windows. Effective union = sum (the user
24
+ // can spend `limit` per each window, independently).
25
+ //
26
+ // The classifier consumes a minimal `RuleRef` shape: the rule id, scope
27
+ // (contract + method), and an optional spending limit + window. Shapes
28
+ // without a limit fall back to scope-only classification.
29
+
30
+ /** Minimal rule reference the classifier needs to compute the conflict kind.
31
+ * Carries the rule id (used in the annotation), the scope (contract +
32
+ * method), and an optional spending cap. The cap captures both the
33
+ * amount and the window so the classifier can compare apples to apples. */
34
+ export interface RuleRef {
35
+ id: string
36
+ scope: { contract?: string; method?: string }
37
+ /** When set, the rule enforces this per-window spend cap (token +
38
+ * decimal-string amount + seconds window). */
39
+ spendingLimit?: { token: string; amount: string; windowSeconds: number }
40
+ }
41
+
42
+ /** Structured annotation a single new-vs-existing rule comparison yields.
43
+ * Four mutually exclusive kinds; the union is exhaustive (the classifier
44
+ * never throws). */
45
+ export type ConflictAnnotation =
46
+ | { kind: 'subsume'; existingRuleId: string; detail: string }
47
+ | { kind: 'disjoint' }
48
+ | { kind: 'counter_permissive'; existingRuleId: string; newLimit: string; existingLimit: string }
49
+ | { kind: 'window_divergent'; existingRuleId: string; effectiveUnion: string }
50
+
51
+ /** Classify the relationship between a new rule and one existing rule.
52
+ * Pure: same pair -> same annotation. */
53
+ export function classifyConflict(newRule: RuleRef, existingRule: RuleRef): ConflictAnnotation {
54
+ // --- step 1: subsume? (new.scope ⊋ existing.scope) ---
55
+ //
56
+ // Subsume requires existing to be fully scoped (both contract AND method
57
+ // pinned) AND new to be at least partially broader than existing. A new
58
+ // rule with identical scope to existing is NOT a superset - it is the
59
+ // same rule and falls through to the same-scope branch below. If existing
60
+ // is "any contract" / "any method" then it is the wildest rule in the
61
+ // rule set; a new rule covering it is just a duplicate, not a subsume -
62
+ // fall through to disjoint.
63
+ const existingHasContract = existingRule.scope.contract !== undefined
64
+ const existingHasMethod = existingRule.scope.method !== undefined
65
+ const newIsBroaderOnContract = newRule.scope.contract === undefined && existingHasContract
66
+ const newIsBroaderOnMethod = newRule.scope.method === undefined && existingHasMethod
67
+ const newMatchesOnContract =
68
+ newRule.scope.contract === undefined ||
69
+ (existingHasContract && newRule.scope.contract === existingRule.scope.contract)
70
+ const newMatchesOnMethod =
71
+ newRule.scope.method === undefined ||
72
+ (existingHasMethod && newRule.scope.method === existingRule.scope.method)
73
+ if (
74
+ existingHasContract &&
75
+ existingHasMethod &&
76
+ newMatchesOnContract &&
77
+ newMatchesOnMethod &&
78
+ (newIsBroaderOnContract || newIsBroaderOnMethod)
79
+ ) {
80
+ return {
81
+ kind: 'subsume',
82
+ existingRuleId: existingRule.id,
83
+ detail: `new rule scope (${describeScope(newRule)}) is a superset of existing rule (${describeScope(existingRule)})`,
84
+ }
85
+ }
86
+
87
+ // --- step 2: disjoint by scope? ---
88
+ if (existingHasContract && newRule.scope.contract !== undefined) {
89
+ if (newRule.scope.contract !== existingRule.scope.contract) {
90
+ return { kind: 'disjoint' }
91
+ }
92
+ }
93
+ if (
94
+ existingHasMethod &&
95
+ newRule.scope.method !== undefined &&
96
+ newRule.scope.method !== existingRule.scope.method
97
+ ) {
98
+ return { kind: 'disjoint' }
99
+ }
100
+
101
+ // --- step 3: same scope -> compare spend caps (when both sides carry them) ---
102
+ if (newRule.spendingLimit && existingRule.spendingLimit) {
103
+ const a = newRule.spendingLimit
104
+ const b = existingRule.spendingLimit
105
+ if (a.token !== b.token) {
106
+ // Same scope, different tokens: each binds a different axis. They
107
+ // do not counter each other; treat as disjoint.
108
+ return { kind: 'disjoint' }
109
+ }
110
+ if (a.windowSeconds === b.windowSeconds) {
111
+ // Same scope, same token, same window: counter-permissive when the
112
+ // amounts disagree (the stricter wins at evaluate; the looser is
113
+ // dead weight). Equal amounts -> duplicate rule, treat as disjoint.
114
+ if (a.amount === b.amount) {
115
+ return { kind: 'disjoint' }
116
+ }
117
+ return {
118
+ kind: 'counter_permissive',
119
+ existingRuleId: existingRule.id,
120
+ newLimit: `${a.amount} ${a.token} / ${a.windowSeconds}s`,
121
+ existingLimit: `${b.amount} ${b.token} / ${b.windowSeconds}s`,
122
+ }
123
+ }
124
+ // Same scope + token, different windows: effective union is the sum
125
+ // of the two per-window caps (each window ticks independently).
126
+ const union = (BigInt(a.amount) + BigInt(b.amount)).toString()
127
+ return {
128
+ kind: 'window_divergent',
129
+ existingRuleId: existingRule.id,
130
+ effectiveUnion: `${union} ${a.token} per ${a.windowSeconds}s (new) + ${b.windowSeconds}s (existing)`,
131
+ }
132
+ }
133
+
134
+ // Same scope without comparable spend caps on both sides -> disjoint.
135
+ return { kind: 'disjoint' }
136
+ }
137
+
138
+ function describeScope(rule: RuleRef): string {
139
+ const parts: string[] = []
140
+ parts.push(rule.scope.contract ? `contract=${rule.scope.contract}` : 'contract=*')
141
+ parts.push(rule.scope.method ? `method=${rule.scope.method}` : 'method=*')
142
+ return parts.join(', ')
143
+ }
@@ -0,0 +1,158 @@
1
+ // src/review-card/cross-check.ts - the non-hallucination guard.
2
+ //
3
+ // `summaryCrossCheck` walks EVERY leaf of the predicate and asserts the
4
+ // review-card summary quoted a constraint string for it. Returns
5
+ // `{ ok: false, missingConstraints }` if any leaf was dropped (or rendered
6
+ // into a template shape the cross-check does not recognise). A
7
+ // `PredicateNode = null` is always ok - the policy is OZ-only and the
8
+ // interpreter predicate did not exist.
9
+ //
10
+ // The guard is structural: every leaf the builder is supposed to render has
11
+ // exactly one template shape. The cross-check enumerates those shapes and
12
+ // demands the summary carries the corresponding string. A naive summary
13
+ // that drops a leaf (or fabricates a string not in the predicate) trips the
14
+ // guard.
15
+
16
+ import type { PredicateLeaf, PredicateNode } from '../types.ts'
17
+ import type { ReviewCardSummary } from './builder.ts'
18
+
19
+ /** Assert every leaf in the predicate appears as a constraint string in
20
+ * `summary.constraints`. Returns the missing templates (without the leaf
21
+ * values filled in) when any leaf was dropped. */
22
+ export function summaryCrossCheck(
23
+ predicate: PredicateNode | null,
24
+ summary: ReviewCardSummary
25
+ ): { ok: true } | { ok: false; missingConstraints: string[] } {
26
+ if (predicate === null) return { ok: true }
27
+
28
+ const expected: string[] = []
29
+ collect(predicate, expected)
30
+
31
+ if (expected.length === 0) return { ok: true }
32
+
33
+ const present = new Set(summary.constraints)
34
+ const missing = expected.filter((s) => !present.has(s))
35
+ if (missing.length === 0) return { ok: true }
36
+ return { ok: false, missingConstraints: missing }
37
+ }
38
+
39
+ /** Walk the predicate and emit the EXACT constraint string the builder is
40
+ * expected to produce for each leaf, in walk order. Strings here MUST
41
+ * match the templates in `builder.ts` byte-for-byte; the cross-check is
42
+ * the structural claim that "every supported leaf shape is rendered". */
43
+ function collect(node: PredicateNode, out: string[]): void {
44
+ switch (node.op) {
45
+ case 'and':
46
+ case 'or':
47
+ for (const child of node.children) collect(child, out)
48
+ return
49
+ case 'not':
50
+ collect(node.child, out)
51
+ return
52
+ case 'eq':
53
+ case 'lt':
54
+ case 'lte':
55
+ case 'gt':
56
+ case 'gte':
57
+ pushComparison(node.left, node.right, node.op, out)
58
+ return
59
+ case 'in':
60
+ pushMembership(node.needle, node.haystack, out)
61
+ return
62
+ }
63
+ }
64
+
65
+ function pushComparison(
66
+ left: PredicateLeaf,
67
+ right: PredicateLeaf,
68
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
69
+ out: string[]
70
+ ): void {
71
+ if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
72
+ out.push(`Contract must be ${right.value}`)
73
+ return
74
+ }
75
+ if (left.kind === 'call_fn' && op === 'eq' && right.kind === 'literal_symbol') {
76
+ out.push(`Function must be ${right.value}`)
77
+ return
78
+ }
79
+ if (left.kind === 'call_arg' && op === 'eq' && right.kind === 'literal_vec') {
80
+ out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`)
81
+ return
82
+ }
83
+ if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
84
+ out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`)
85
+ return
86
+ }
87
+ if (left.kind === 'amount' && right.kind === 'literal_i128') {
88
+ out.push(`Amount <= ${right.value}`)
89
+ return
90
+ }
91
+ if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
92
+ out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`)
93
+ return
94
+ }
95
+ }
96
+
97
+ function pushMembership(needle: PredicateLeaf, haystack: PredicateLeaf[], out: string[]): void {
98
+ if (needle.kind !== 'call_arg') return
99
+ const list = haystack.map(renderHaystackElement).join(', ')
100
+ out.push(`Recipient/arg must be one of [${list}]`)
101
+ }
102
+
103
+ function renderVecElement(leaf: PredicateLeaf): string {
104
+ switch (leaf.kind) {
105
+ case 'literal_address':
106
+ return leaf.value
107
+ case 'literal_i128':
108
+ return leaf.value
109
+ case 'literal_symbol':
110
+ return leaf.value
111
+ case 'literal_u32':
112
+ return String(leaf.value)
113
+ case 'literal_u64':
114
+ return leaf.value
115
+ case 'literal_bytes':
116
+ return leaf.value
117
+ case 'literal_vec':
118
+ return `[${leaf.elements.map(renderVecElement).join(', ')}]`
119
+ case 'call_contract':
120
+ case 'call_fn':
121
+ case 'call_arg':
122
+ case 'amount':
123
+ case 'window_spent':
124
+ case 'now':
125
+ case 'valid_until':
126
+ case 'invocation_count_in_window':
127
+ case 'oracle_price':
128
+ return `<${leaf.kind}>`
129
+ }
130
+ }
131
+
132
+ function renderHaystackElement(leaf: PredicateLeaf): string {
133
+ if (leaf.kind === 'literal_address') return leaf.value
134
+ if (leaf.kind === 'literal_i128') return leaf.value
135
+ if (leaf.kind === 'literal_symbol') return leaf.value
136
+ if (leaf.kind === 'literal_u32') return String(leaf.value)
137
+ if (leaf.kind === 'literal_u64') return leaf.value
138
+ if (leaf.kind === 'literal_bytes') return leaf.value
139
+ if (leaf.kind === 'literal_vec') {
140
+ return `[${leaf.elements.map(renderHaystackElement).join(', ')}]`
141
+ }
142
+ return `<${leaf.kind}>`
143
+ }
144
+
145
+ function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string {
146
+ switch (op) {
147
+ case 'lt':
148
+ return '<'
149
+ case 'lte':
150
+ return '<='
151
+ case 'gt':
152
+ return '>'
153
+ case 'gte':
154
+ return '>='
155
+ case 'eq':
156
+ return '=='
157
+ }
158
+ }
@@ -0,0 +1,12 @@
1
+ // src/review-card/index.ts - re-export the deterministic review-card surface.
2
+
3
+ export {
4
+ buildReviewCardSummary,
5
+ type ReviewCardSummary,
6
+ } from './builder.ts'
7
+ export {
8
+ type ConflictAnnotation,
9
+ classifyConflict,
10
+ type RuleRef,
11
+ } from './conflict.ts'
12
+ export { summaryCrossCheck } from './cross-check.ts'
@@ -48,7 +48,7 @@ export interface CustodyCapabilities {
48
48
  export interface CompileResult {
49
49
  /** false => some IR construct this backend cannot express (see `uncovered`). */
50
50
  covered: boolean
51
- /** Human-readable list of unsupported constructs (the Path-B markers). */
51
+ /** Human-readable list of unsupported constructs. */
52
52
  uncovered: string[]
53
53
  /** The backend-native installable policy, assembled when a rule lowered. */
54
54
  proposed?: ProposedPolicy