lcp-sdk 1.0.1 → 1.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.
- checksums.yaml +4 -4
- data/README.md +23 -0
- data/lib/generated_models.rb +3 -3
- data/lib/lcp_sdk.rb +76 -1
- data/lib/schema-bundle.json +154 -55
- data/lib/schema_validator.rb +50 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 248c45361c2b78f8127e776f3d11d4dd71a9ff1a8b07e21fb3c85582a9e476dd
|
|
4
|
+
data.tar.gz: 3b6861a3b2c9203f7daa6948f8bbe94c34849c5f1dd6c3303c7bc019c151cc7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45ad7782cae8db681571f677e1c5b3ade5a7a2ef7a397703b1a12dfbec96c8a791629b84479b3addef307b8252c13666d61a1a941d5c7a176826a7536a04cebb
|
|
7
|
+
data.tar.gz: 294d1fd238144a806ef492ee55baa2702b5d730cf8728966f3661a0b1a4ad6734c0cb65cedf4b3b30ac75eb3100515f8d43d12bdb2b0abecede91fcad5ea1abd
|
data/README.md
CHANGED
|
@@ -11,3 +11,26 @@ The SDK provides generated payload models, `LcpSdk::SchemaValidator` full Draft
|
|
|
11
11
|
`LcpSdk::Signing` raw-body HMAC helpers, webhook verification, and
|
|
12
12
|
`LcpSdk::Client` operations for leads, calls, bids, status, capabilities, and
|
|
13
13
|
offers. See the shared [SDK contract](../../../docs/SDK-ROADMAP.md).
|
|
14
|
+
## Shared lead slots and contact hashes (v1.1, LEP-0003)
|
|
15
|
+
|
|
16
|
+
The typed payload declarations gained the optional v1.1 fields
|
|
17
|
+
`slots_available` (ping), `slots_claimed` (bid), and `contacted_hashes`
|
|
18
|
+
(post). The SDK ships the only sanctioned contact-hash builder —
|
|
19
|
+
`HMAC-SHA256(pair_secret, normalized_preimage)` (lowercase hex), reusing the
|
|
20
|
+
package's existing HMAC-SHA256 signing primitive: phone preimages are the
|
|
21
|
+
verbatim wire bytes per SPEC.md §9; email preimages use the §9 pinned
|
|
22
|
+
candidate canonical (trim surrounding whitespace, ASCII-lowercase the domain,
|
|
23
|
+
local-part bytes verbatim, plus-addresses distinct), and email-form
|
|
24
|
+
`contacted_hashes` ENTRIES stay emission-gated until the #153 T9 executed
|
|
25
|
+
vectors publish. Pinned rows live in
|
|
26
|
+
[`test-vectors/sdk/contact-hash.json`](../../../test-vectors/sdk/contact-hash.json).
|
|
27
|
+
Slot fields are capability-gated, never ignore-gated (SPEC.md §8): senders
|
|
28
|
+
MUST only include them toward a receiver advertising a full
|
|
29
|
+
`major.minor.patch` element with `major == 1` and `minor >= 1`, compared as
|
|
30
|
+
parsed semver; the example below shows the gate and the SDK validator
|
|
31
|
+
rejecting slot fields toward a v1.0-only advertisement.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
digest = LcpSdk::Signing.contact_hash(buyer_pair_secret, "+15125550147", "phone")
|
|
35
|
+
validator.validate_envelope(ping, receiver_advertised_lcp_versions: caps["lcp_versions"])
|
|
36
|
+
```
|
data/lib/generated_models.rb
CHANGED
|
@@ -4,9 +4,9 @@ module LcpSdk
|
|
|
4
4
|
EnvelopeModel = Struct.new(:version, :message, :payload, keyword_init: true)
|
|
5
5
|
LeadPayload = Struct.new(:lead_id, :status, :channel, :consumer, :location, :attributes, :external_id, :submitted_at, :compliance, :provenance, :exclusivity, :contact_window, :lead_quality, :expiry, :attachments, keyword_init: true)
|
|
6
6
|
CallPayload = Struct.new(:lead_id, :status, :channel, :consumer, :location, :call, :external_id, :submitted_at, :compliance, :provenance, :attributes, :expiry, :exclusivity, :attachments, keyword_init: true)
|
|
7
|
-
PingPayload = Struct.new(:ping_id, :lead_reference, :phone_hash, :country_code, :vertical, :floor_price_cents, :currency, :publisher_id, :offer_id, :email_hash, :attributes, keyword_init: true)
|
|
8
|
-
PostPayload = Struct.new(:lead_id, :delivered_at, :price_cents, :currency, :buyer_id, :consumer, :location, :attributes, :submitted_at, :offer_id, :buyer_reference, :pricing, :compliance, :provenance, :call, :attachments, keyword_init: true)
|
|
9
|
-
BidPayload = Struct.new(:ping_id, :decision, :bid_price_cents, :currency, :estimated_contact_seconds, :buyer_reference, :reject_reason, :capacity_remaining, keyword_init: true)
|
|
7
|
+
PingPayload = Struct.new(:ping_id, :lead_reference, :phone_hash, :country_code, :vertical, :floor_price_cents, :currency, :publisher_id, :offer_id, :email_hash, :attributes, :slots_available, keyword_init: true)
|
|
8
|
+
PostPayload = Struct.new(:lead_id, :delivered_at, :price_cents, :currency, :buyer_id, :consumer, :location, :attributes, :submitted_at, :offer_id, :buyer_reference, :pricing, :compliance, :provenance, :contacted_hashes, :call, :attachments, keyword_init: true)
|
|
9
|
+
BidPayload = Struct.new(:ping_id, :decision, :bid_price_cents, :currency, :estimated_contact_seconds, :buyer_reference, :reject_reason, :capacity_remaining, :slots_claimed, keyword_init: true)
|
|
10
10
|
AckPayload = Struct.new(:original_message_id, :status, :errors, :lead_id, :request_id, :rejection_reason, keyword_init: true)
|
|
11
11
|
EventPayload = Struct.new(:lead_id, :event, :timestamp, :details, :external_reference, keyword_init: true)
|
|
12
12
|
OfferModel = Struct.new(:offer_id, :buyer_id, :vertical, :countries, :floor_price_cents, :currency, :tenant_id, :active, :routing_mode, :schema_version, :extensions, :allowed_publisher_ids, :allowed_brand_ids, :attribute_equals, :attribute_in, :monthly_minimum_payable, :monthly_maximum_payable, :monthly_quota_timezone, :monthly_quota_policy, :payable_rules, :call_routing_mode, :connect_timeout_seconds, keyword_init: true)
|
data/lib/lcp_sdk.rb
CHANGED
|
@@ -20,7 +20,47 @@ module LcpSdk
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def sign(secret, timestamp, idempotency_key, body)
|
|
23
|
-
|
|
23
|
+
hmac_hex(secret, canonical_bytes(timestamp, idempotency_key, body))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The single HMAC-SHA256 → lowercase-hex construction in this SDK (SPEC.md §9).
|
|
27
|
+
# Signing and contact hashes share it; nothing else computes a keyed digest.
|
|
28
|
+
def hmac_hex(secret, data)
|
|
29
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, data)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
CONTACT_HASH_DOMAINS = ["phone", "email"].freeze
|
|
33
|
+
|
|
34
|
+
# SPEC.md §9 pinned contact preimage. Phone: the wire value verbatim,
|
|
35
|
+
# byte-for-byte — no folding, stripping, or reformatting (the reference
|
|
36
|
+
# platform's phone_hash builder is the executed baseline). Email: the §9
|
|
37
|
+
# pinned candidate canonical — trim surrounding whitespace, ASCII-lowercase
|
|
38
|
+
# the domain, keep the local-part bytes verbatim, plus-addresses distinct.
|
|
39
|
+
# Email-form contacted_hashes ENTRIES stay emission-gated until #153 T9
|
|
40
|
+
# executed vectors publish; this is the pinned compute rule every
|
|
41
|
+
# contact-hash vector row reproduces under.
|
|
42
|
+
def normalize_contact(value, domain = "phone")
|
|
43
|
+
case domain
|
|
44
|
+
when "phone" then value
|
|
45
|
+
when "email"
|
|
46
|
+
trimmed = value.strip
|
|
47
|
+
separator = trimmed.rindex("@")
|
|
48
|
+
raise ArgumentError, "email contact preimage must contain '@'" unless separator
|
|
49
|
+
local = trimmed[0...separator]
|
|
50
|
+
host = trimmed[(separator + 1)..].chars.map { |c| c =~ /\A[A-Z]\z/ ? c.downcase : c }.join
|
|
51
|
+
"#{local}@#{host}"
|
|
52
|
+
else
|
|
53
|
+
raise ArgumentError, "unknown contact domain #{domain.inspect} (expected phone or email)"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Per-pair contact hash (SPEC.md §9, LEP-0003 §3): lowercase-hex
|
|
58
|
+
# HMAC-SHA256(pair_secret, normalized_preimage) keyed with the secret
|
|
59
|
+
# shared with the message's receiver. phone_hash, email_hash, and
|
|
60
|
+
# contacted_hashes entries all ride this one construction — never an
|
|
61
|
+
# unsalted digest, never SHA-1.
|
|
62
|
+
def contact_hash(secret, value, domain = "phone")
|
|
63
|
+
hmac_hex(secret, normalize_contact(value, domain))
|
|
24
64
|
end
|
|
25
65
|
|
|
26
66
|
def verify(secret, signature, timestamp, idempotency_key, body, max_skew_seconds: 300, now: Time.now.utc)
|
|
@@ -62,6 +102,41 @@ module LcpSdk
|
|
|
62
102
|
true
|
|
63
103
|
end
|
|
64
104
|
|
|
105
|
+
# SPEC.md §8 capability gate (LEP-0003): v1.1 slot fields are
|
|
106
|
+
# capability-gated, never ignore-gated — the ping/bid/post objects are
|
|
107
|
+
# closed, so a v1.0 schema-validating receiver REJECTS the fields. Senders
|
|
108
|
+
# MUST only include them toward a receiver whose lcp_versions advertisement
|
|
109
|
+
# parses to a full major.minor.patch element with major == 1 and minor >= 1
|
|
110
|
+
# (never a string literal or prefix).
|
|
111
|
+
SLOT_FIELDS = ["slots_available", "slots_claimed", "contacted_hashes"].freeze
|
|
112
|
+
|
|
113
|
+
def receiver_advertises_slot_fields(advertised_lcp_versions)
|
|
114
|
+
Array(advertised_lcp_versions).any? do |element|
|
|
115
|
+
parts = element.to_s.split(".")
|
|
116
|
+
next false unless parts.size == 3 && parts.all? { |part| !part.empty? && part.match?(/\A\d+\z/) }
|
|
117
|
+
parts[0].to_i == 1 && parts[1].to_i >= 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def slot_fields_present(payload)
|
|
122
|
+
return [] unless payload.is_a?(Hash)
|
|
123
|
+
present = SLOT_FIELDS.select { |field| payload.key?(field) }
|
|
124
|
+
if payload["event"] == "CONTACTED" && payload["details"].is_a?(Hash) && payload["details"].key?("contacted_hashes")
|
|
125
|
+
present = present + ["details.contacted_hashes"]
|
|
126
|
+
end
|
|
127
|
+
present
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Errors when an envelope carries v1.1 slot fields but the receiver's
|
|
131
|
+
# advertisement does not support them (§8 gate; invalid example D). An empty
|
|
132
|
+
# list is capability-compatibility, not a schema verdict.
|
|
133
|
+
def slot_capability_errors(envelope, advertised_lcp_versions)
|
|
134
|
+
payload = envelope.dig("lcp", "payload")
|
|
135
|
+
present = slot_fields_present(payload)
|
|
136
|
+
return [] if present.empty? || receiver_advertises_slot_fields(advertised_lcp_versions)
|
|
137
|
+
present.map { |field| "#{field} requires the receiver to advertise a full-semver LCP version with major 1 and minor >= 1 (SPEC.md §8 capability gate)" }
|
|
138
|
+
end
|
|
139
|
+
|
|
65
140
|
class Client
|
|
66
141
|
def initialize(endpoint, sender_id: nil, api_key: nil, hmac_secret: nil, max_retries: 2)
|
|
67
142
|
@endpoint = endpoint.delete_suffix("/"); @sender_id = sender_id; @api_key = api_key; @hmac_secret = hmac_secret; @max_retries = max_retries
|
data/lib/schema-bundle.json
CHANGED
|
@@ -53,6 +53,26 @@
|
|
|
53
53
|
"$id": "https://lcp.dev/schemas/bid.json",
|
|
54
54
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
55
55
|
"additionalProperties": false,
|
|
56
|
+
"allOf": [
|
|
57
|
+
{
|
|
58
|
+
"if": {
|
|
59
|
+
"properties": {
|
|
60
|
+
"decision": {
|
|
61
|
+
"const": "accept"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"required": [
|
|
65
|
+
"decision"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"then": {
|
|
69
|
+
"required": [
|
|
70
|
+
"bid_price_cents",
|
|
71
|
+
"currency"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
],
|
|
56
76
|
"description": "Buyer's response to a ping. Contains the bid price, accept/reject decision, and estimated contact speed. The platform collects bids from all pinged buyers and selects the winner. Created by Spear Systems (a Spear company). Open standard \u2014 Apache 2.0, free to implement.",
|
|
57
77
|
"properties": {
|
|
58
78
|
"bid_price_cents": {
|
|
@@ -93,13 +113,17 @@
|
|
|
93
113
|
"reject_reason": {
|
|
94
114
|
"description": "Why the buyer rejected (e.g. out_of_hours, capacity_full, vertical_mismatch, price_too_low). Open enumeration. Optional.",
|
|
95
115
|
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
"slots_claimed": {
|
|
118
|
+
"description": "v1.1 (LEP-0003 \u00a72): buyer slots this buyer claims if selected. The claim is a request, not a reservation \u2014 the platform awards slots at post time (delivery is the claim event; bids are non-binding). Only meaningful with decision 'accept'; absent = 1. Capped at exactly 1 for v1.1: multi-slot agency claims (N > 1) have no delivery path on the per-(lead, offer) award guard and no awarded-count wire signal, so they are deferred (LEP-0003 Deferred questions, #153 T5) \u2014 this schema enforces maximum: 1 now. Capability-gated (SPEC.md \u00a78): senders MUST NOT include v1.1 slot fields toward receivers whose lcp_versions advertisement does not parse to major == 1, minor >= 1.",
|
|
119
|
+
"maximum": 1,
|
|
120
|
+
"minimum": 1,
|
|
121
|
+
"type": "integer"
|
|
96
122
|
}
|
|
97
123
|
},
|
|
98
124
|
"required": [
|
|
99
125
|
"ping_id",
|
|
100
|
-
"decision"
|
|
101
|
-
"bid_price_cents",
|
|
102
|
-
"currency"
|
|
126
|
+
"decision"
|
|
103
127
|
],
|
|
104
128
|
"title": "LCP Bid Message",
|
|
105
129
|
"type": "object"
|
|
@@ -640,7 +664,7 @@
|
|
|
640
664
|
},
|
|
641
665
|
"locale": {
|
|
642
666
|
"description": "BCP-47 language tag (e.g. en-AU, es-US).",
|
|
643
|
-
"pattern": "^[a-z]{2,3}(-[A-Z][a-
|
|
667
|
+
"pattern": "^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$",
|
|
644
668
|
"type": "string"
|
|
645
669
|
},
|
|
646
670
|
"phone": {
|
|
@@ -823,6 +847,8 @@
|
|
|
823
847
|
"type": "string"
|
|
824
848
|
},
|
|
825
849
|
"attestation": {
|
|
850
|
+
"maxLength": 8192,
|
|
851
|
+
"pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",
|
|
826
852
|
"type": "string"
|
|
827
853
|
}
|
|
828
854
|
},
|
|
@@ -1118,6 +1144,17 @@
|
|
|
1118
1144
|
"expiry": {
|
|
1119
1145
|
"$ref": "core.json#/$defs/expiry"
|
|
1120
1146
|
},
|
|
1147
|
+
"extensions": {
|
|
1148
|
+
"additionalProperties": true,
|
|
1149
|
+
"description": "v1.1 (LEP-0006 Track B): namespaced deployment-specific data (SPEC.md section 6; governance/EXTENSION-REGISTRY.md). Keys MUST follow {org}.{division}.{purpose} - at least three lowercase dot-separated segments - and lcp.core.* is reserved for the core. Values stay deployment-defined: the core does not and cannot validate namespaced payloads. Shape authority for a registered namespace is its record (LEP-0006 section B.2 defines {org}.form.questions: at most 25 entries of question_id/question_text/answer_text, question_text at most 300 and answer_text at most 1000 characters). These are receiver rules, not schema rules: a receiver that implements a registered form.questions namespace MUST validate entries and accept or reject the message (LCP-100), while receivers that do not recognize a namespace ignore its value. Bound hierarchy (LEP-0006 section B.2): the registered form.questions shape ceiling is 133,140 B worst-case UTF-8, receivers SHOULD reject extension totals above the 32 KiB soft policy target (a fully shape-conformant sender can exceed it; issue #197 tracks the re-tune), and the operative hard bound is the transport body cap (max_body_bytes, default 2,000,000 B). Never ping_safe: this field exists only on lead and post - ping/bid/ack keep their strict allowlists - and a platform MUST NOT derive ping attributes, compliance_flags, or any other ping field from extension content. Extension content is lead/post PII-scope content: it inherits section 9 retention and erasure, and question/answer text MUST NOT carry contact values, government identifiers, payment credentials, consent tokens, or evidence values (those have structured homes in consumer, compliance.consent_evidence[], and attachments[]).",
|
|
1150
|
+
"propertyNames": {
|
|
1151
|
+
"not": {
|
|
1152
|
+
"pattern": "^lcp\\.core\\."
|
|
1153
|
+
},
|
|
1154
|
+
"pattern": "^[a-z0-9][a-z0-9_-]*(\\.[a-z0-9][a-z0-9_-]*){2,}$"
|
|
1155
|
+
},
|
|
1156
|
+
"type": "object"
|
|
1157
|
+
},
|
|
1121
1158
|
"external_id": {
|
|
1122
1159
|
"description": "Publisher's internal lead ID.",
|
|
1123
1160
|
"type": "string"
|
|
@@ -1573,6 +1610,28 @@
|
|
|
1573
1610
|
"$id": "https://lcp.dev/schemas/ping.json",
|
|
1574
1611
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
1575
1612
|
"additionalProperties": false,
|
|
1613
|
+
"allOf": [
|
|
1614
|
+
{
|
|
1615
|
+
"$comment": "LEP-0003 \u00a71/F8 (invalid example B): exclusive is exactly the one-slot special case. When the exclusivity hint is present and 'exclusive', slots_available MUST be 1 \u2014 an exclusive ping advertising more is a semantic error, rejected by this conditional, not just by prose. Absent slots_available stays legal (v1.0 behavior).",
|
|
1616
|
+
"if": {
|
|
1617
|
+
"properties": {
|
|
1618
|
+
"exclusivity": {
|
|
1619
|
+
"const": "exclusive"
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
"required": [
|
|
1623
|
+
"exclusivity"
|
|
1624
|
+
]
|
|
1625
|
+
},
|
|
1626
|
+
"then": {
|
|
1627
|
+
"properties": {
|
|
1628
|
+
"slots_available": {
|
|
1629
|
+
"const": 1
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
],
|
|
1576
1635
|
"description": "Non-PII offer to buyers. STRICT ALLOWLIST \u2014 additionalProperties: false. No PII fields are permitted. The conformance runner also validates that every field in attributes is tagged ping_safe: true in the vertical schema. Created by Spear Systems (a Spear company). Open standard \u2014 Apache 2.0, free to implement.",
|
|
1577
1636
|
"properties": {
|
|
1578
1637
|
"attributes": {
|
|
@@ -1731,6 +1790,11 @@
|
|
|
1731
1790
|
},
|
|
1732
1791
|
"type": "object"
|
|
1733
1792
|
},
|
|
1793
|
+
"slots_available": {
|
|
1794
|
+
"description": "v1.1 (LEP-0003): remaining buyer-award slots of this lead that can still be sold at ping time, INCLUDING the slot this ping's recipient may win. A slot counts a distinct awarded buyer, never delivered copies (SPEC.md \u00a73 slot semantics). For exclusivity 'exclusive' this MUST be 1 \u2014 the one-slot special case (enforced by the top-level conditional in this schema). Absent = the platform does not advertise slot state (v1.0 behavior). A platform MUST NOT send slots_available: 0; zero remaining means stop pinging. Non-PII integer count carrying no consumer data: it joins the ping strict allowlist as a reviewed non-PII field, and conformance keeps rejecting every other field outside the allowlist (LCP-008 PII_IN_PING protection preserved) and every attribute not tagged ping_safe: true in the vertical schema. Capability-gated (SPEC.md \u00a78): senders MUST NOT include v1.1 slot fields toward receivers whose lcp_versions advertisement does not parse to major == 1, minor >= 1.",
|
|
1795
|
+
"minimum": 1,
|
|
1796
|
+
"type": "integer"
|
|
1797
|
+
},
|
|
1734
1798
|
"state_region": {
|
|
1735
1799
|
"type": "string"
|
|
1736
1800
|
},
|
|
@@ -1866,6 +1930,16 @@
|
|
|
1866
1930
|
"consumer": {
|
|
1867
1931
|
"$ref": "core.json#/$defs/consumer"
|
|
1868
1932
|
},
|
|
1933
|
+
"contacted_hashes": {
|
|
1934
|
+
"$comment": "No uniqueItems (LEP-0003 \u00a74): the wire array tolerates duplicates; the receiver merges by digest set per lead (at-least-once + set-union accumulation). maxItems 100: the record leaves the post-direction array bound open as a Deferred question ('a lead's contact attempts are naturally bounded'); this implementation bound is set generously above any realistic per-lead contact history so no conformant sender is clipped, while keeping the post payload bounded and rejecting bulk-suppression-sync misuse of this field (bulk sync stays out of scope; if production posts ever approach this bound, bulk routes to a dedicated mechanism). The CONTACTED event-report direction has its own tighter ingest bound (32 entries per report, SPEC.md \u00a74).",
|
|
1935
|
+
"description": "v1.1 (LEP-0003 \u00a73): parties already contacted for this lead by the publisher or prior slot holders, re-keyed under THIS receiving buyer's per-pair secret. Each entry is HMAC-SHA256(pair_secret, contact-preimage) \u2014 lowercase 64-hex, the same form as phone_hash (SPEC.md \u00a79); the unsalted SHA-1-shaped digest of the reference network is explicitly NOT adopted (enumerable keyspaces invert it; \u00a714 blocker B3). The platform matches digests only against values it holds in plaintext for this pair and cannot verify reported digests without the plaintext \u2014 the trust model is format + count sanity plus authenticated-sender attestation, scoped to one lead_id. Absent = the platform has no contact reports; [] = the platform attests no prior contacts (the two are distinct). Preimage rules and the phone-only/email-gated state are normative in SPEC.md \u00a79. Capability-gated (SPEC.md \u00a78): senders MUST NOT include v1.1 fields toward receivers whose lcp_versions advertisement does not parse to major == 1, minor >= 1.",
|
|
1936
|
+
"items": {
|
|
1937
|
+
"pattern": "^[a-f0-9]{64}$",
|
|
1938
|
+
"type": "string"
|
|
1939
|
+
},
|
|
1940
|
+
"maxItems": 100,
|
|
1941
|
+
"type": "array"
|
|
1942
|
+
},
|
|
1869
1943
|
"currency": {
|
|
1870
1944
|
"$ref": "core.json#/$defs/currency"
|
|
1871
1945
|
},
|
|
@@ -1876,6 +1950,17 @@
|
|
|
1876
1950
|
"exclusivity": {
|
|
1877
1951
|
"$ref": "core.json#/$defs/exclusivity"
|
|
1878
1952
|
},
|
|
1953
|
+
"extensions": {
|
|
1954
|
+
"additionalProperties": true,
|
|
1955
|
+
"description": "v1.1 (LEP-0006 Track B): namespaced deployment-specific data (SPEC.md section 6; governance/EXTENSION-REGISTRY.md). Keys MUST follow {org}.{division}.{purpose} - at least three lowercase dot-separated segments - and lcp.core.* is reserved for the core. Values stay deployment-defined: the core does not and cannot validate namespaced payloads. Shape authority for a registered namespace is its record (LEP-0006 section B.2 defines {org}.form.questions: at most 25 entries of question_id/question_text/answer_text, question_text at most 300 and answer_text at most 1000 characters). These are receiver rules, not schema rules: a receiver that implements a registered form.questions namespace MUST validate entries and accept or reject the message (LCP-100), while receivers that do not recognize a namespace ignore its value. Bound hierarchy (LEP-0006 section B.2): the registered form.questions shape ceiling is 133,140 B worst-case UTF-8, receivers SHOULD reject extension totals above the 32 KiB soft policy target (a fully shape-conformant sender can exceed it; issue #197 tracks the re-tune), and the operative hard bound is the transport body cap (max_body_bytes, default 2,000,000 B). Never ping_safe: this field exists only on lead and post - ping/bid/ack keep their strict allowlists - and a platform MUST NOT derive ping attributes, compliance_flags, or any other ping field from extension content. Extension content is lead/post PII-scope content: it inherits section 9 retention and erasure, and question/answer text MUST NOT carry contact values, government identifiers, payment credentials, consent tokens, or evidence values (those have structured homes in consumer, compliance.consent_evidence[], and attachments[]).",
|
|
1956
|
+
"propertyNames": {
|
|
1957
|
+
"not": {
|
|
1958
|
+
"pattern": "^lcp\\.core\\."
|
|
1959
|
+
},
|
|
1960
|
+
"pattern": "^[a-z0-9][a-z0-9_-]*(\\.[a-z0-9][a-z0-9_-]*){2,}$"
|
|
1961
|
+
},
|
|
1962
|
+
"type": "object"
|
|
1963
|
+
},
|
|
1879
1964
|
"lead_id": {
|
|
1880
1965
|
"type": "string"
|
|
1881
1966
|
},
|
|
@@ -2231,14 +2316,14 @@
|
|
|
2231
2316
|
"description": "Insurance lead attributes. Every field is tagged ping_safe: true|false \u2014 the conformance runner rejects any ping containing a non-ping_safe field. Created by Spear Systems (a Spear company). Open standard \u2014 Apache 2.0, free to implement.",
|
|
2232
2317
|
"properties": {
|
|
2233
2318
|
"claim_count_band": {
|
|
2234
|
-
"description": "Banded number of claims filed.",
|
|
2319
|
+
"description": "Banded number of claims filed. Sensitive underwriting data; post-only.",
|
|
2235
2320
|
"enum": [
|
|
2236
2321
|
"none",
|
|
2237
2322
|
"one",
|
|
2238
2323
|
"two",
|
|
2239
2324
|
"three_or_more"
|
|
2240
2325
|
],
|
|
2241
|
-
"ping_safe":
|
|
2326
|
+
"ping_safe": false,
|
|
2242
2327
|
"type": "string"
|
|
2243
2328
|
},
|
|
2244
2329
|
"coverage_amount_band": {
|
|
@@ -2307,23 +2392,23 @@
|
|
|
2307
2392
|
"type": "string"
|
|
2308
2393
|
},
|
|
2309
2394
|
"gender": {
|
|
2310
|
-
"description": "Gender \u2014
|
|
2395
|
+
"description": "Gender \u2014 sensitive underwriting data; post-only.",
|
|
2311
2396
|
"enum": [
|
|
2312
2397
|
"male",
|
|
2313
2398
|
"female",
|
|
2314
2399
|
"non_binary",
|
|
2315
2400
|
"prefer_not_to_say"
|
|
2316
2401
|
],
|
|
2317
|
-
"ping_safe":
|
|
2402
|
+
"ping_safe": false,
|
|
2318
2403
|
"type": "string"
|
|
2319
2404
|
},
|
|
2320
2405
|
"has_claims_history": {
|
|
2321
|
-
"description": "Has the consumer filed any claims?",
|
|
2322
|
-
"ping_safe":
|
|
2406
|
+
"description": "Has the consumer filed any claims? Sensitive underwriting data; post-only.",
|
|
2407
|
+
"ping_safe": false,
|
|
2323
2408
|
"type": "boolean"
|
|
2324
2409
|
},
|
|
2325
2410
|
"health_condition_band": {
|
|
2326
|
-
"description": "Self-reported health condition band for health insurance.",
|
|
2411
|
+
"description": "Self-reported health condition band for health insurance; sensitive health data, post-only.",
|
|
2327
2412
|
"enum": [
|
|
2328
2413
|
"excellent",
|
|
2329
2414
|
"good",
|
|
@@ -2331,11 +2416,11 @@
|
|
|
2331
2416
|
"poor",
|
|
2332
2417
|
"unknown"
|
|
2333
2418
|
],
|
|
2334
|
-
"ping_safe":
|
|
2419
|
+
"ping_safe": false,
|
|
2335
2420
|
"type": "string"
|
|
2336
2421
|
},
|
|
2337
2422
|
"height_band": {
|
|
2338
|
-
"description": "Banded height for health/life insurance underwriting.",
|
|
2423
|
+
"description": "Banded height for health/life insurance underwriting; sensitive health data, post-only.",
|
|
2339
2424
|
"enum": [
|
|
2340
2425
|
"under_5ft",
|
|
2341
2426
|
"5_5ft5",
|
|
@@ -2343,7 +2428,7 @@
|
|
|
2343
2428
|
"5ft9_6ft",
|
|
2344
2429
|
"over_6ft"
|
|
2345
2430
|
],
|
|
2346
|
-
"ping_safe":
|
|
2431
|
+
"ping_safe": false,
|
|
2347
2432
|
"type": "string"
|
|
2348
2433
|
},
|
|
2349
2434
|
"insurance_type": {
|
|
@@ -2366,11 +2451,11 @@
|
|
|
2366
2451
|
"separated",
|
|
2367
2452
|
"domestic_partnership"
|
|
2368
2453
|
],
|
|
2369
|
-
"ping_safe":
|
|
2454
|
+
"ping_safe": false,
|
|
2370
2455
|
"type": "string"
|
|
2371
2456
|
},
|
|
2372
2457
|
"occupation_class": {
|
|
2373
|
-
"description": "Occupation risk class for life/disability insurance underwriting.",
|
|
2458
|
+
"description": "Occupation risk class for life/disability insurance underwriting; sensitive employment data, post-only.",
|
|
2374
2459
|
"enum": [
|
|
2375
2460
|
"professional",
|
|
2376
2461
|
"clerical",
|
|
@@ -2380,7 +2465,7 @@
|
|
|
2380
2465
|
"self_employed",
|
|
2381
2466
|
"other"
|
|
2382
2467
|
],
|
|
2383
|
-
"ping_safe":
|
|
2468
|
+
"ping_safe": false,
|
|
2384
2469
|
"type": "string"
|
|
2385
2470
|
},
|
|
2386
2471
|
"property_type": {
|
|
@@ -2402,8 +2487,8 @@
|
|
|
2402
2487
|
"type": "string"
|
|
2403
2488
|
},
|
|
2404
2489
|
"smoker": {
|
|
2405
|
-
"description": "Smoking status for health/life insurance underwriting.",
|
|
2406
|
-
"ping_safe":
|
|
2490
|
+
"description": "Smoking status for health/life insurance underwriting; sensitive health data, post-only.",
|
|
2491
|
+
"ping_safe": false,
|
|
2407
2492
|
"type": "boolean"
|
|
2408
2493
|
},
|
|
2409
2494
|
"vehicle_type": {
|
|
@@ -2427,7 +2512,7 @@
|
|
|
2427
2512
|
"type": "string"
|
|
2428
2513
|
},
|
|
2429
2514
|
"weight_band": {
|
|
2430
|
-
"description": "Banded weight (lbs) for health/life insurance underwriting.",
|
|
2515
|
+
"description": "Banded weight (lbs) for health/life insurance underwriting; sensitive health data, post-only.",
|
|
2431
2516
|
"enum": [
|
|
2432
2517
|
"under_120",
|
|
2433
2518
|
"120_150",
|
|
@@ -2436,7 +2521,7 @@
|
|
|
2436
2521
|
"210_250",
|
|
2437
2522
|
"over_250"
|
|
2438
2523
|
],
|
|
2439
|
-
"ping_safe":
|
|
2524
|
+
"ping_safe": false,
|
|
2440
2525
|
"type": "string"
|
|
2441
2526
|
},
|
|
2442
2527
|
"years_with_current_insurer_band": {
|
|
@@ -2466,8 +2551,8 @@
|
|
|
2466
2551
|
"description": "Legal lead attributes. Every field is tagged ping_safe: true|false \u2014 the conformance runner rejects any ping containing a non-ping_safe field. Created by Spear Systems (a Spear company). Open standard \u2014 Apache 2.0, free to implement.",
|
|
2467
2552
|
"properties": {
|
|
2468
2553
|
"at_fault": {
|
|
2469
|
-
"description": "Was the consumer at fault?
|
|
2470
|
-
"ping_safe":
|
|
2554
|
+
"description": "Was the consumer at fault? Sensitive legal liability data; post-only.",
|
|
2555
|
+
"ping_safe": false,
|
|
2471
2556
|
"type": "boolean"
|
|
2472
2557
|
},
|
|
2473
2558
|
"case_type": {
|
|
@@ -2485,7 +2570,7 @@
|
|
|
2485
2570
|
"type": "string"
|
|
2486
2571
|
},
|
|
2487
2572
|
"claim_status": {
|
|
2488
|
-
"description": "Current status of the claim.",
|
|
2573
|
+
"description": "Current status of the claim; sensitive legal data, post-only.",
|
|
2489
2574
|
"enum": [
|
|
2490
2575
|
"not_filed",
|
|
2491
2576
|
"filed",
|
|
@@ -2494,12 +2579,12 @@
|
|
|
2494
2579
|
"settled",
|
|
2495
2580
|
"in_litigation"
|
|
2496
2581
|
],
|
|
2497
|
-
"ping_safe":
|
|
2582
|
+
"ping_safe": false,
|
|
2498
2583
|
"type": "string"
|
|
2499
2584
|
},
|
|
2500
2585
|
"has_attorney": {
|
|
2501
|
-
"description": "Does the consumer already have an attorney?",
|
|
2502
|
-
"ping_safe":
|
|
2586
|
+
"description": "Does the consumer already have an attorney? Legal-representation data; post-only.",
|
|
2587
|
+
"ping_safe": false,
|
|
2503
2588
|
"type": "boolean"
|
|
2504
2589
|
},
|
|
2505
2590
|
"incident_date": {
|
|
@@ -2514,7 +2599,7 @@
|
|
|
2514
2599
|
"type": "string"
|
|
2515
2600
|
},
|
|
2516
2601
|
"injury_severity": {
|
|
2517
|
-
"description": "Severity of injury sustained.",
|
|
2602
|
+
"description": "Severity of injury sustained; sensitive health/legal data, post-only.",
|
|
2518
2603
|
"enum": [
|
|
2519
2604
|
"none",
|
|
2520
2605
|
"minor",
|
|
@@ -2523,7 +2608,7 @@
|
|
|
2523
2608
|
"catastrophic",
|
|
2524
2609
|
"fatal"
|
|
2525
2610
|
],
|
|
2526
|
-
"ping_safe":
|
|
2611
|
+
"ping_safe": false,
|
|
2527
2612
|
"type": "string"
|
|
2528
2613
|
},
|
|
2529
2614
|
"insurance_company": {
|
|
@@ -2532,13 +2617,13 @@
|
|
|
2532
2617
|
"type": "string"
|
|
2533
2618
|
},
|
|
2534
2619
|
"medical_treatment_received": {
|
|
2535
|
-
"description": "Has medical treatment been received?",
|
|
2536
|
-
"ping_safe":
|
|
2620
|
+
"description": "Has medical treatment been received? Sensitive health/legal data; post-only.",
|
|
2621
|
+
"ping_safe": false,
|
|
2537
2622
|
"type": "boolean"
|
|
2538
2623
|
},
|
|
2539
2624
|
"police_report_filed": {
|
|
2540
|
-
"description": "Was a police report filed?",
|
|
2541
|
-
"ping_safe":
|
|
2625
|
+
"description": "Was a police report filed? Sensitive incident data; post-only.",
|
|
2626
|
+
"ping_safe": false,
|
|
2542
2627
|
"type": "boolean"
|
|
2543
2628
|
},
|
|
2544
2629
|
"schema_version": {
|
|
@@ -2546,10 +2631,10 @@
|
|
|
2546
2631
|
"type": "string"
|
|
2547
2632
|
},
|
|
2548
2633
|
"time_to_contact_deadline_days": {
|
|
2549
|
-
"description": "Days remaining to contact before statute of limitations or deadline.",
|
|
2634
|
+
"description": "Days remaining to contact before statute of limitations or deadline; sensitive legal timing, post-only.",
|
|
2550
2635
|
"maximum": 3650,
|
|
2551
2636
|
"minimum": 0,
|
|
2552
|
-
"ping_safe":
|
|
2637
|
+
"ping_safe": false,
|
|
2553
2638
|
"type": "integer"
|
|
2554
2639
|
},
|
|
2555
2640
|
"vertical": {
|
|
@@ -2589,8 +2674,8 @@
|
|
|
2589
2674
|
"type": "integer"
|
|
2590
2675
|
},
|
|
2591
2676
|
"bankruptcy_history": {
|
|
2592
|
-
"description": "Has the consumer filed for bankruptcy?
|
|
2593
|
-
"ping_safe":
|
|
2677
|
+
"description": "Has the consumer filed for bankruptcy? Sensitive financial data; post-only.",
|
|
2678
|
+
"ping_safe": false,
|
|
2594
2679
|
"type": "boolean"
|
|
2595
2680
|
},
|
|
2596
2681
|
"cash_out_amount_band": {
|
|
@@ -2611,6 +2696,7 @@
|
|
|
2611
2696
|
"type": "integer"
|
|
2612
2697
|
},
|
|
2613
2698
|
"credit_rating_band": {
|
|
2699
|
+
"description": "Credit classification is sensitive financial data; post-only.",
|
|
2614
2700
|
"enum": [
|
|
2615
2701
|
"excellent",
|
|
2616
2702
|
"good",
|
|
@@ -2618,7 +2704,7 @@
|
|
|
2618
2704
|
"poor",
|
|
2619
2705
|
"unknown"
|
|
2620
2706
|
],
|
|
2621
|
-
"ping_safe":
|
|
2707
|
+
"ping_safe": false,
|
|
2622
2708
|
"type": "string"
|
|
2623
2709
|
},
|
|
2624
2710
|
"current_lender": {
|
|
@@ -2660,8 +2746,8 @@
|
|
|
2660
2746
|
"type": "number"
|
|
2661
2747
|
},
|
|
2662
2748
|
"foreclosure_history": {
|
|
2663
|
-
"description": "Has the consumer had a foreclosure?
|
|
2664
|
-
"ping_safe":
|
|
2749
|
+
"description": "Has the consumer had a foreclosure? Sensitive financial data; post-only.",
|
|
2750
|
+
"ping_safe": false,
|
|
2665
2751
|
"type": "boolean"
|
|
2666
2752
|
},
|
|
2667
2753
|
"has_existing_mortgage": {
|
|
@@ -2861,8 +2947,8 @@
|
|
|
2861
2947
|
"type": "string"
|
|
2862
2948
|
},
|
|
2863
2949
|
"self_employed_income_verified": {
|
|
2864
|
-
"description": "For self-employed leads, whether income has been verified
|
|
2865
|
-
"ping_safe":
|
|
2950
|
+
"description": "For self-employed leads, whether income has been verified; sensitive financial data, post-only.",
|
|
2951
|
+
"ping_safe": false,
|
|
2866
2952
|
"type": "boolean"
|
|
2867
2953
|
},
|
|
2868
2954
|
"vertical": {
|
|
@@ -2870,13 +2956,14 @@
|
|
|
2870
2956
|
"type": "string"
|
|
2871
2957
|
},
|
|
2872
2958
|
"veteran_status": {
|
|
2959
|
+
"description": "Veteran or military status is sensitive eligibility data; post-only.",
|
|
2873
2960
|
"enum": [
|
|
2874
2961
|
"veteran",
|
|
2875
2962
|
"active_duty",
|
|
2876
2963
|
"reservist",
|
|
2877
2964
|
"non_veteran"
|
|
2878
2965
|
],
|
|
2879
|
-
"ping_safe":
|
|
2966
|
+
"ping_safe": false,
|
|
2880
2967
|
"type": "string"
|
|
2881
2968
|
}
|
|
2882
2969
|
},
|
|
@@ -2932,7 +3019,8 @@
|
|
|
2932
3019
|
"type": "string"
|
|
2933
3020
|
},
|
|
2934
3021
|
"claim_filed": {
|
|
2935
|
-
"
|
|
3022
|
+
"description": "Claim status is sensitive legal/insurance data; post-only.",
|
|
3023
|
+
"ping_safe": false,
|
|
2936
3024
|
"type": "boolean"
|
|
2937
3025
|
},
|
|
2938
3026
|
"claim_stage": {
|
|
@@ -2952,16 +3040,18 @@
|
|
|
2952
3040
|
"type": "boolean"
|
|
2953
3041
|
},
|
|
2954
3042
|
"consumer_insured": {
|
|
3043
|
+
"description": "Consumer insurance status is sensitive insurance data; post-only.",
|
|
2955
3044
|
"enum": [
|
|
2956
3045
|
"yes",
|
|
2957
3046
|
"no",
|
|
2958
3047
|
"unknown"
|
|
2959
3048
|
],
|
|
2960
|
-
"ping_safe":
|
|
3049
|
+
"ping_safe": false,
|
|
2961
3050
|
"type": "string"
|
|
2962
3051
|
},
|
|
2963
3052
|
"emergency_services_called": {
|
|
2964
|
-
"
|
|
3053
|
+
"description": "Emergency-response information is sensitive incident data; post-only.",
|
|
3054
|
+
"ping_safe": false,
|
|
2965
3055
|
"type": "boolean"
|
|
2966
3056
|
},
|
|
2967
3057
|
"evidence_available": {
|
|
@@ -2983,6 +3073,7 @@
|
|
|
2983
3073
|
"uniqueItems": true
|
|
2984
3074
|
},
|
|
2985
3075
|
"fault_position": {
|
|
3076
|
+
"description": "Fault position is sensitive legal/liability data; post-only.",
|
|
2986
3077
|
"enum": [
|
|
2987
3078
|
"not_at_fault",
|
|
2988
3079
|
"partially_at_fault",
|
|
@@ -2990,14 +3081,16 @@
|
|
|
2990
3081
|
"unclear",
|
|
2991
3082
|
"unknown"
|
|
2992
3083
|
],
|
|
2993
|
-
"ping_safe":
|
|
3084
|
+
"ping_safe": false,
|
|
2994
3085
|
"type": "string"
|
|
2995
3086
|
},
|
|
2996
3087
|
"injury_present": {
|
|
2997
|
-
"
|
|
3088
|
+
"description": "Injury indicator is sensitive health/legal data; post-only.",
|
|
3089
|
+
"ping_safe": false,
|
|
2998
3090
|
"type": "boolean"
|
|
2999
3091
|
},
|
|
3000
3092
|
"injury_severity_band": {
|
|
3093
|
+
"description": "Injury severity is sensitive health/legal data; post-only.",
|
|
3001
3094
|
"enum": [
|
|
3002
3095
|
"none",
|
|
3003
3096
|
"minor",
|
|
@@ -3006,7 +3099,7 @@
|
|
|
3006
3099
|
"fatality",
|
|
3007
3100
|
"unknown"
|
|
3008
3101
|
],
|
|
3009
|
-
"ping_safe":
|
|
3102
|
+
"ping_safe": false,
|
|
3010
3103
|
"type": "string"
|
|
3011
3104
|
},
|
|
3012
3105
|
"jurisdiction": {
|
|
@@ -3015,6 +3108,7 @@
|
|
|
3015
3108
|
"type": "string"
|
|
3016
3109
|
},
|
|
3017
3110
|
"medical_treatment": {
|
|
3111
|
+
"description": "Medical treatment is sensitive health data; post-only.",
|
|
3018
3112
|
"enum": [
|
|
3019
3113
|
"none",
|
|
3020
3114
|
"self_treated",
|
|
@@ -3023,20 +3117,22 @@
|
|
|
3023
3117
|
"ongoing",
|
|
3024
3118
|
"unknown"
|
|
3025
3119
|
],
|
|
3026
|
-
"ping_safe":
|
|
3120
|
+
"ping_safe": false,
|
|
3027
3121
|
"type": "string"
|
|
3028
3122
|
},
|
|
3029
3123
|
"other_driver_identified": {
|
|
3030
|
-
"
|
|
3124
|
+
"description": "Other-party identification status is sensitive incident data; post-only.",
|
|
3125
|
+
"ping_safe": false,
|
|
3031
3126
|
"type": "boolean"
|
|
3032
3127
|
},
|
|
3033
3128
|
"other_driver_insured": {
|
|
3129
|
+
"description": "Other-party insurance status is sensitive insurance data; post-only.",
|
|
3034
3130
|
"enum": [
|
|
3035
3131
|
"yes",
|
|
3036
3132
|
"no",
|
|
3037
3133
|
"unknown"
|
|
3038
3134
|
],
|
|
3039
|
-
"ping_safe":
|
|
3135
|
+
"ping_safe": false,
|
|
3040
3136
|
"type": "string"
|
|
3041
3137
|
},
|
|
3042
3138
|
"passenger_count_band": {
|
|
@@ -3051,15 +3147,18 @@
|
|
|
3051
3147
|
"type": "string"
|
|
3052
3148
|
},
|
|
3053
3149
|
"police_report_available": {
|
|
3054
|
-
"
|
|
3150
|
+
"description": "Police-report availability is sensitive incident data; post-only.",
|
|
3151
|
+
"ping_safe": false,
|
|
3055
3152
|
"type": "boolean"
|
|
3056
3153
|
},
|
|
3057
3154
|
"prior_attorney_consultation": {
|
|
3058
|
-
"
|
|
3155
|
+
"description": "Prior legal consultation is sensitive legal data; post-only.",
|
|
3156
|
+
"ping_safe": false,
|
|
3059
3157
|
"type": "boolean"
|
|
3060
3158
|
},
|
|
3061
3159
|
"represented_by_attorney": {
|
|
3062
|
-
"
|
|
3160
|
+
"description": "Attorney-representation status is sensitive legal data; post-only.",
|
|
3161
|
+
"ping_safe": false,
|
|
3063
3162
|
"type": "boolean"
|
|
3064
3163
|
},
|
|
3065
3164
|
"schema_version": {
|
data/lib/schema_validator.rb
CHANGED
|
@@ -30,6 +30,13 @@ module LcpSdk
|
|
|
30
30
|
name = path.delete_prefix("#{root}/")
|
|
31
31
|
documents[name] = JSON.parse(File.read(path))
|
|
32
32
|
end
|
|
33
|
+
if File.basename(root.to_s) == "schemas"
|
|
34
|
+
vertical_root = File.join(File.dirname(root), "verticals")
|
|
35
|
+
Dir.glob(File.join(vertical_root, "**", "*.json")).each do |path|
|
|
36
|
+
name = path.delete_prefix("#{vertical_root}/")
|
|
37
|
+
documents["verticals/#{name}"] = JSON.parse(File.read(path))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
33
40
|
new(documents)
|
|
34
41
|
end
|
|
35
42
|
|
|
@@ -46,11 +53,52 @@ module LcpSdk
|
|
|
46
53
|
true
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
def validate_envelope(envelope)
|
|
56
|
+
def validate_envelope(envelope, receiver_advertised_lcp_versions: nil)
|
|
50
57
|
validate("schemas/envelope.json", envelope)
|
|
58
|
+
# Optional SPEC.md §8 capability gate: slot fields toward a receiver that
|
|
59
|
+
# does not advertise v1.1 are a conformance failure (the v1.0 closed
|
|
60
|
+
# allowlist cannot parse them).
|
|
61
|
+
if receiver_advertised_lcp_versions
|
|
62
|
+
gate_errors = LcpSdk.slot_capability_errors(envelope, receiver_advertised_lcp_versions)
|
|
63
|
+
raise SchemaValidationError, "LCP capability gate: #{gate_errors.join('; ')}" unless gate_errors.empty?
|
|
64
|
+
end
|
|
51
65
|
type = envelope.dig("lcp", "message", "type")
|
|
52
66
|
raise SchemaValidationError, "missing lcp.message.type" unless type.is_a?(String)
|
|
53
|
-
|
|
67
|
+
payload = envelope.dig("lcp", "payload")
|
|
68
|
+
validate("schemas/#{type}.json", payload)
|
|
69
|
+
validate_vertical_policy(type, payload)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_vertical_policy(type, payload)
|
|
73
|
+
return unless %w[lead call post ping].include?(type) && payload.is_a?(Hash)
|
|
74
|
+
attributes = payload["attributes"]
|
|
75
|
+
return unless attributes.is_a?(Hash)
|
|
76
|
+
vertical = type == "ping" ? payload["vertical"] : attributes["vertical"]
|
|
77
|
+
return unless vertical.is_a?(String) && !vertical.empty?
|
|
78
|
+
schema = @documents[normalize("verticals/#{vertical}.json")] ||
|
|
79
|
+
@documents["verticals/#{vertical}.json"] ||
|
|
80
|
+
@documents["#{vertical}.json"]
|
|
81
|
+
raise SchemaValidationError, "Vertical schema '#{vertical}' not found" unless schema.is_a?(Hash)
|
|
82
|
+
vertical_attributes = JSON.parse(JSON.generate(attributes))
|
|
83
|
+
if type == "ping"
|
|
84
|
+
vertical_attributes["vertical"] ||= vertical
|
|
85
|
+
vertical_attributes["schema_version"] ||= schema.dig("properties", "schema_version", "const") || "1.0.0"
|
|
86
|
+
end
|
|
87
|
+
validate_vertical(vertical, vertical_attributes)
|
|
88
|
+
validate_ping_safe(vertical, attributes, schema, "attributes") if type == "ping"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_ping_safe(vertical, value, schema, path)
|
|
92
|
+
return unless value.is_a?(Hash)
|
|
93
|
+
properties = schema.fetch("properties", {})
|
|
94
|
+
value.each do |name, child|
|
|
95
|
+
next if path == "attributes" && %w[vertical schema_version].include?(name)
|
|
96
|
+
definition = properties[name]
|
|
97
|
+
unless definition.is_a?(Hash) && definition["ping_safe"] == true
|
|
98
|
+
raise SchemaValidationError, "#{path}.#{name} is not tagged ping_safe: true in vertical '#{vertical}'"
|
|
99
|
+
end
|
|
100
|
+
validate_ping_safe(vertical, child, definition, "#{path}.#{name}") if child.is_a?(Hash) && definition["properties"].is_a?(Hash)
|
|
101
|
+
end
|
|
54
102
|
end
|
|
55
103
|
|
|
56
104
|
def validate_offer(offer)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lcp-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Spear Systems
|
|
@@ -64,7 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
65
|
version: '0'
|
|
66
66
|
requirements: []
|
|
67
|
-
rubygems_version: 4.0.
|
|
67
|
+
rubygems_version: 4.0.20
|
|
68
68
|
specification_version: 4
|
|
69
69
|
summary: LCP Lead Context Protocol SDK
|
|
70
70
|
test_files: []
|