lcp-sdk 1.0.2 → 1.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62c022daa49deaca29686086da6cb24e3b8f8cba2fd65fc5fd8443f306d0ce66
4
- data.tar.gz: ef2ff43e0a408396682c561924f25aaa53d4bfba7b6dedef3a564bdb133d188f
3
+ metadata.gz: 703d40a739d0c90ec564532625d80d28c118177c6ffabea4435e00d276a299b1
4
+ data.tar.gz: 3b6861a3b2c9203f7daa6948f8bbe94c34849c5f1dd6c3303c7bc019c151cc7d
5
5
  SHA512:
6
- metadata.gz: 277d5be9e17a4e2ef371958c59e054eb99f3da93b016c498c6645fc1509cf2cc3fd5ce32352bde969e55fde44d8852144efc1fd8cfbe9c63cec54d984a7f711f
7
- data.tar.gz: 1d7b8d764af3c8adbbbf8ff2ab6236450553b3b5313ede6375798c8d87ed2fc60fa821b656dfa113ae0164f750ec6511e0d47caf56a1b844ce5b2fc02f1fa2a8
6
+ metadata.gz: 162396e876b349241cf03a867480fb9bfd4240a0ce454db072540f173ed70332e57ed982aac35e088c6cb4be823ef487b970773f3941535cba0d6760184cf948
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
+ ```
@@ -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
- OpenSSL::HMAC.hexdigest("SHA256", secret, canonical_bytes(timestamp, idempotency_key, body))
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
@@ -113,6 +113,12 @@
113
113
  "reject_reason": {
114
114
  "description": "Why the buyer rejected (e.g. out_of_hours, capacity_full, vertical_mismatch, price_too_low). Open enumeration. Optional.",
115
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"
116
122
  }
117
123
  },
118
124
  "required": [
@@ -658,7 +664,7 @@
658
664
  },
659
665
  "locale": {
660
666
  "description": "BCP-47 language tag (e.g. en-AU, es-US).",
661
- "pattern": "^[a-z]{2,3}(-[A-Z][a-zA-Z]{1,7})?$",
667
+ "pattern": "^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$",
662
668
  "type": "string"
663
669
  },
664
670
  "phone": {
@@ -841,6 +847,8 @@
841
847
  "type": "string"
842
848
  },
843
849
  "attestation": {
850
+ "maxLength": 8192,
851
+ "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",
844
852
  "type": "string"
845
853
  }
846
854
  },
@@ -1136,6 +1144,17 @@
1136
1144
  "expiry": {
1137
1145
  "$ref": "core.json#/$defs/expiry"
1138
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
+ },
1139
1158
  "external_id": {
1140
1159
  "description": "Publisher's internal lead ID.",
1141
1160
  "type": "string"
@@ -1591,6 +1610,28 @@
1591
1610
  "$id": "https://lcp.dev/schemas/ping.json",
1592
1611
  "$schema": "https://json-schema.org/draft/2020-12/schema",
1593
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
+ ],
1594
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.",
1595
1636
  "properties": {
1596
1637
  "attributes": {
@@ -1749,6 +1790,11 @@
1749
1790
  },
1750
1791
  "type": "object"
1751
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
+ },
1752
1798
  "state_region": {
1753
1799
  "type": "string"
1754
1800
  },
@@ -1884,6 +1930,16 @@
1884
1930
  "consumer": {
1885
1931
  "$ref": "core.json#/$defs/consumer"
1886
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
+ },
1887
1943
  "currency": {
1888
1944
  "$ref": "core.json#/$defs/currency"
1889
1945
  },
@@ -1894,6 +1950,17 @@
1894
1950
  "exclusivity": {
1895
1951
  "$ref": "core.json#/$defs/exclusivity"
1896
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
+ },
1897
1964
  "lead_id": {
1898
1965
  "type": "string"
1899
1966
  },
@@ -53,8 +53,15 @@ module LcpSdk
53
53
  true
54
54
  end
55
55
 
56
- def validate_envelope(envelope)
56
+ def validate_envelope(envelope, receiver_advertised_lcp_versions: nil)
57
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
58
65
  type = envelope.dig("lcp", "message", "type")
59
66
  raise SchemaValidationError, "missing lcp.message.type" unless type.is_a?(String)
60
67
  payload = envelope.dig("lcp", "payload")
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.2
4
+ version: 1.1.1
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.16
67
+ rubygems_version: 4.0.20
68
68
  specification_version: 4
69
69
  summary: LCP Lead Context Protocol SDK
70
70
  test_files: []