bfocus 0.2.0 → 0.2.2

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: 6989b99e0788e360a77f8853de9a52f5e9628db20515a2ccbb9c57fd173e4c23
4
- data.tar.gz: 3e3a8766862577d33ddf9e8bb3959b380fbd995f43e97688bd7f8bf05d3a06c2
3
+ metadata.gz: cde8597ee9adbdde78b052fc3527eb19ed6a5045f73829424d835f696dcd95c9
4
+ data.tar.gz: 867ddf4b2644cbe30252ac5ca6d482e6a4a5216049a4469e6f5f2d5ed4f5757b
5
5
  SHA512:
6
- metadata.gz: 337c578cf5bde255220540a83297d298a89a08f97e31f72a274a25b7614c5c9b68b9cd782a95f39382ed3b858019d42a0dd16a4fc66483a281e6e43bce1e2b18
7
- data.tar.gz: 5291a2a3e6b1cd5e6fd6bd7bf9d5dfb2e3abc12aa14cccc70b9fc1d2400eba5980e56edc62e2ccfa988acff36c3afb6bff13f9180cbb2e1c27f610bdc2c45716
6
+ metadata.gz: 7a5abc2452240b5f164b164e72bb4e2d895cd70e4ff6479f64d7ba39fa34ecbf0af48e32d75c3c5fe97642f566edf0007e23ab3ec0c8ea921140bbc62614ecda
7
+ data.tar.gz: 91a342991a7a3859e6664b94bd73282fe1d3892226de023372243325bb3a2f738c580575126e0fda360298b310b8b36eed00118165540331f24c9591450add7f
data/README.md CHANGED
@@ -154,6 +154,22 @@ client.people.identifiers.add("app-77", "crm-p5")
154
154
  client.people.identifiers.remove("app-77", "crm-p5")
155
155
  ```
156
156
 
157
+ ### Ler os identificadores da pessoa (para reconciliar)
158
+
159
+ `client.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
160
+ cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter sumido
161
+ do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
162
+
163
+ `people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela era
164
+ preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no caminho o id
165
+ principal **ou qualquer um dos extras**.
166
+
167
+ ```ruby
168
+ ids = client.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
169
+ ids["external_id"] # => "app-77" — o principal do cadastro
170
+ ids["identifiers"].each { |i| puts "#{i['external_id']} #{i['label']} #{i['source']}" }
171
+ ```
172
+
157
173
  ## Pessoas
158
174
 
159
175
  As pessoas (usuários do seu sistema) de cada cliente, em `client.people`. O `external_id` da pessoa
@@ -174,10 +190,111 @@ client.people.upsert("erp-1042", "app-77", access: true) # devolve o acesso
174
190
 
175
191
  - **Nunca duplica.** O e-mail (ou o telefone) acha a pessoa que já chegou por e-mail, pelo widget
176
192
  ou por outro sistema, e ela é **adotada** (ganha o seu `external_id`).
177
- - A mesma pessoa enviada com **outro cliente** é **transferida** para ele.
193
+ - A mesma pessoa enviada com **outro cliente** NÃO é transferida: fica **ligada** também a ele
194
+ (`"linked" => true` na resposta). O cadastro é único e a mesma pessoa circula por vários clientes.
195
+ - **O acesso é do vínculo.** `delete` (e `access: false`) tira o acesso dela NESTE cliente, não nos
196
+ outros: `"unlinked" => true` na resposta quer dizer que ela segue ativa em algum outro.
178
197
  - Como no resto da SDK, só o que você passa muda; `nil` limpa (`phone: nil`).
179
198
  - Campos: `name`, `email`, `phone`, `role`, `access` (pode usar o atendimento), `is_primary`
180
- (contato principal), `extra_emails`, `extra_phones`.
199
+ (contato principal), `extra_emails`, `extra_phones`, `custom_fields`, `clear`.
200
+ - Erros comuns (`code`): `CUSTOMER_NOT_FOUND`, `NAME_REQUIRED` (ao criar), `PERSON_EMAIL_TAKEN`,
201
+ `PERSON_PHONE_TAKEN`, `PERSON_CONTACT_OTHER_CUSTOMER`, `PERSON_EMAIL_STAFF`,
202
+ `PERSON_CLEAR_FIELD_INVALID`, `PERSON_CLEAR_NOT_OWN_RECORD`.
203
+
204
+ ### Campos personalizados da pessoa
205
+
206
+ `custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
207
+ **exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
208
+ ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
209
+ em nada, como em qualquer outro campo.
210
+
211
+ A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
212
+ no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
213
+ querer.
214
+
215
+ Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
216
+
217
+ ```ruby
218
+ pessoa = client.people.upsert(
219
+ "erp-1042", "app-77",
220
+ custom_fields: [ # a lista INTEIRA do seu sistema
221
+ { key: "matricula", label: "Matrícula", value: "4471" },
222
+ { key: "filial", label: "Filial", value: "Centro" }
223
+ ]
224
+ )
225
+ pessoa["custom_fields"].each do |campo|
226
+ puts "#{campo['key']} #{campo['value']} #{campo['visibility']}" # visibility vem do bFocus
227
+ end
228
+ ```
229
+
230
+ ### Apagar o e-mail ou o telefone da pessoa
231
+
232
+ Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o telefone,
233
+ nenhum reenvio o soltava. `clear` apaga.
234
+
235
+ ```ruby
236
+ client.people.upsert("erp-1042", "app-77", clear: ["phone"]) # some o telefone
237
+ client.people.upsert("erp-1042", "app-77", clear: %w[email phone]) # somem os dois
238
+ ```
239
+
240
+ Três regras que parecem contraintuitivas e são de propósito:
241
+
242
+ - **Apagar é explícito.** `phone: nil`, `clear: []` e não passar o argumento continuam significando
243
+ **"não mexe"** — a SDK não traduz `nil` em `clear`. Fazer o `nil` apagar teria apagado, em
244
+ silêncio e na primeira carga seguinte, o dado de todo sistema que manda `nil` para "não tenho
245
+ esse valor".
246
+ - **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer outro
247
+ devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`Bfocus::ValidationError`).
248
+ - **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a API
249
+ recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`Bfocus::ConflictError`): apagar o contato de uma
250
+ ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você tem em
251
+ mãos é o principal ou um extra, use `client.people.identifiers.list(...)`.
252
+
253
+ Vale no `people.upsert` e no `people.batch` (`"clear" => ["phone"]` no item).
254
+
255
+ ### Contato já usado: um 409 que você consegue resolver
256
+
257
+ `PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
258
+ telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `error.data` (a API repete o mesmo
259
+ detalhe em `error.validation`, por compatibilidade):
260
+
261
+ | campo | o que é |
262
+ | --- | --- |
263
+ | `field` | `email` ou `phone` — qual contato está tomado |
264
+ | `owner_external_id` | o identificador da pessoa que já usa esse contato |
265
+ | `owner_name` | o nome dela |
266
+ | `owner_customer_external_id` | o cliente a que ela pertence |
267
+
268
+ **É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
269
+
270
+ - **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
271
+ tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
272
+ essa pessoa.
273
+ - **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
274
+ e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
275
+ casamento por e-mail.
276
+
277
+ ```ruby
278
+ begin
279
+ client.people.upsert("erp-1042", "app-77", name: "Paula Reis", email: "paula@padaria.example")
280
+ rescue Bfocus::ConflictError => e
281
+ raise unless %w[PERSON_EMAIL_TAKEN PERSON_PHONE_TAKEN].include?(e.code)
282
+
283
+ dono = e.data
284
+ if dono["owner_customer_external_id"] == "erp-1042"
285
+ # A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
286
+ client.people.identifiers.add(dono["owner_external_id"], "app-77", label: "ERP")
287
+ else
288
+ # Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
289
+ avisar_cadastro(e.code, dono)
290
+ end
291
+ end
292
+ ```
293
+
294
+ `PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
295
+ definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
296
+ telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
297
+ falha temporária.
181
298
 
182
299
  ## Lotes — `customers.batch` e `people.batch`
183
300
 
@@ -410,10 +527,10 @@ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer mome
410
527
  | Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
411
528
  | Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
412
529
  | Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
413
- | Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id` |
530
+ | Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id`, `custom_fields` (lista de `{key, label, value, visibility}`) |
414
531
  | Pessoa gravada (`people.upsert`) | a pessoa + `status` (`created`/`updated`/`unchanged`) |
415
532
  | Cliente com identificadores (`customers.identifiers.add`, `remove`) | o cliente + `identifiers` (lista de `{external_id, label, source}`) |
416
- | Identificadores da pessoa (`people.identifiers.add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
533
+ | Identificadores da pessoa (`people.identifiers.list`, `add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
417
534
  | Lote (`customers.batch`, `people.batch`) | `results` (lista de `{index, status, external_id, merged_into, error, code}`; `status` ∈ `created`/`updated`/`unchanged`/`error`), `summary` (`{created, updated, unchanged, error}`) |
418
535
  | Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
419
536
  | Release note (`release_notes.*`) | `id`, `product`, `version`, `title`, `description_html`, `audience`, `is_published`, `require_ack_internal`, `require_ack_external`, `published_at`, `created_at`, `updated_at` |
@@ -440,7 +557,10 @@ Qualquer resposta fora de 2xx levanta `Bfocus::Error` (ou uma subclasse):
440
557
  | `Bfocus::ServerError` | 5xx |
441
558
  | `Bfocus::NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
442
559
 
443
- Todas têm `code`, `status`, `request_id`, `validation`, `retry_after`, `required_scope` e `body`.
560
+ Todas têm `code`, `status`, `request_id`, `validation`, `data`, `retry_after`, `required_scope` e
561
+ `body`. O `data` é o `data` do corpo: o detalhe estruturado que alguns erros trazem (`{}` quando
562
+ não há) — é por ele que um 409 de contato tomado diz de **quem** é o contato (veja
563
+ [Pessoas](#pessoas)).
444
564
  **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
445
565
  `MODULE_NOT_CONTRACTED`, `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao
446
566
  falar com o suporte, informe o `request_id`: ele vem do corpo da resposta, senão do header
data/lib/bfocus/errors.rb CHANGED
@@ -21,6 +21,12 @@ module Bfocus
21
21
  attr_reader :request_id
22
22
  # @return [Hash{String=>String}] motivos por campo (erros de validação); `{}` quando não há.
23
23
  attr_reader :validation
24
+ # @return [Hash] o `data` do corpo do erro: o detalhe estruturado que alguns erros trazem
25
+ # (`{}` quando não há). É onde vem, por exemplo, de quem é o contato já usado num 409
26
+ # `PERSON_EMAIL_TAKEN`/`PERSON_PHONE_TAKEN` (`field`, `owner_external_id`, `owner_name`,
27
+ # `owner_customer_external_id`) e o `owner` de um `IDENTIFIER_IN_USE`. A API repete esse
28
+ # detalhe em {#validation}, por compatibilidade com as SDKs que ainda não expunham `data`.
29
+ attr_reader :data
24
30
  # @return [Integer, Float, nil] segundos do header `Retry-After` (só em 429).
25
31
  attr_reader :retry_after
26
32
  # @return [String, nil] escopo que faltou na chave (header `X-Required-Scope`, só em 403).
@@ -29,11 +35,12 @@ module Bfocus
29
35
  attr_reader :body
30
36
 
31
37
  def initialize(message = nil, code: nil, status: 0, request_id: nil, validation: nil,
32
- retry_after: nil, required_scope: nil, body: nil)
38
+ retry_after: nil, required_scope: nil, body: nil, data: nil)
33
39
  @code = code
34
40
  @status = status
35
41
  @request_id = request_id
36
42
  @validation = validation.is_a?(Hash) ? validation.dup : {}
43
+ @data = data.is_a?(Hash) ? data.dup : {}
37
44
  @retry_after = retry_after
38
45
  @required_scope = required_scope
39
46
  @body = body
@@ -8,6 +8,21 @@ module Bfocus
8
8
  # Retorno: `{"external_id", "identifiers" => [{"external_id", "label", "source"}, …]}`.
9
9
  # Id que já pertence a outro cadastro: `ConflictError` com `code == "IDENTIFIER_IN_USE"`.
10
10
  class PersonIdentifiers < Base
11
+ # Todos os identificadores da pessoa: o principal (`"external_id"` do retorno) e os extras.
12
+ # `GET /people/{person_external_id}/identifiers` (escopo `customers:read`). Aceita no
13
+ # caminho o principal OU qualquer um dos extras.
14
+ #
15
+ # É a fonte de verdade para RECONCILIAR: {People#list} mostra só o identificador principal,
16
+ # então um id que virou extra some de lá sem ter sumido do cadastro — e, sem esta leitura,
17
+ # era preciso ESCREVER (tentar um {#add}) para descobrir o que tinha acontecido.
18
+ #
19
+ # @return [Hash]
20
+ # @raise [Bfocus::NotFoundError] `PERSON_NOT_FOUND`.
21
+ def list(person_external_id, timeout: nil)
22
+ call("GET", "/people/#{segment(person_external_id, 'person_external_id')}/identifiers",
23
+ timeout: timeout)
24
+ end
25
+
11
26
  # Liga `extra_id` à pessoa (idempotente). `PUT /people/{person_external_id}/identifiers/{extra_id}`
12
27
  #
13
28
  # @param label [String, nil] rótulo livre. Não informado = sem corpo.
@@ -35,7 +50,8 @@ module Bfocus
35
50
  #
36
51
  # Pessoa: `"external_id"` (pode ser `nil` para quem chegou por e-mail/widget sem id),
37
52
  # `"name"`, `"email"`, `"phone"`, `"role"`, `"access"`, `"is_primary"`,
38
- # `"customer_external_id"`. O `upsert` devolve também `"status"`
53
+ # `"customer_external_id"` e `"custom_fields"` (lista de `{"key", "label", "value",
54
+ # "visibility"}`). O `upsert` devolve também `"status"`
39
55
  # (`"created"`/`"updated"`/`"unchanged"`).
40
56
  #
41
57
  # O `external_id` da pessoa é o mesmo `user_external_id` assinado no widget — por isso não
@@ -53,22 +69,38 @@ module Bfocus
53
69
  # `PUT /customers/{customer_external_id}/people/{person_external_id}`
54
70
  #
55
71
  # Só o que vier muda; `nil` limpa. O e-mail (ou telefone) acha a pessoa que já chegou por
56
- # outro caminho e ela é adotada, nunca duplicada; se ela estava em outro cliente, é
57
- # transferida. `access: true` devolve o acesso retirado por {#delete}.
72
+ # outro caminho e ela é adotada, nunca duplicada. Se ela existia em OUTRO cliente, NÃO é
73
+ # transferida: fica ligada também a este (cadastro único, `"linked" => true` na resposta).
74
+ # `access: true` devolve o acesso retirado por {#delete}.
58
75
  #
59
76
  # @param access [Boolean] pode abrir chamados/usar o widget.
60
77
  # @param is_primary [Boolean] contato principal do cliente.
61
78
  # @param extra_emails [Array<String>] e-mails adicionais.
62
79
  # @param extra_phones [Array<String>] telefones adicionais.
63
- # @return [Hash] a pessoa + `"status"`.
80
+ # @param custom_fields [Array<Hash>] campos personalizados (`{"key", "label", "value"}`).
81
+ # Ao contrário de `extra_emails`/`extra_phones`, a lista SUBSTITUI a lista inteira: mande
82
+ # o que o seu sistema tem hoje, porque campo que ficar de fora é REMOVIDO. Não passar o
83
+ # argumento não mexe em nada. A visibilidade é decidida no bFocus e preservada entre
84
+ # sincronizações.
85
+ # @param clear [Array<String>] campos a APAGAR nesta pessoa: `["email"]`, `["phone"]` ou os
86
+ # dois. Apagar é EXPLÍCITO: `phone: nil`, `clear: []` e não passar o argumento continuam
87
+ # significando "não mexe" — a SDK não traduz `nil` em `clear`. Campo fora da lista aceita
88
+ # é RECUSADO pela API (422 `PERSON_CLEAR_FIELD_INVALID`), não ignorado; e só se limpa a
89
+ # PRÓPRIA ficha: alcançando a pessoa por um identificador EXTRA, a API recusa (409
90
+ # `PERSON_CLEAR_NOT_OWN_RECORD`) — apagar contato de ficha alcançada por apelido seria
91
+ # apagar dado de outro sistema.
92
+ # @return [Hash] a pessoa + `"status"`, `"linked"` (já existia em outro cliente e agora está
93
+ # ligada a este também) e `"merged_into"` (o id que você mandou era um apelido; este é o
94
+ # principal do cadastro).
64
95
  def upsert(customer_external_id, person_external_id, name: UNSET, email: UNSET, phone: UNSET,
65
96
  role: UNSET, access: UNSET, is_primary: UNSET, extra_emails: UNSET, extra_phones: UNSET,
66
- idempotency_key: nil, timeout: nil)
97
+ custom_fields: UNSET, clear: UNSET, idempotency_key: nil, timeout: nil)
67
98
  cid = segment(customer_external_id, "customer_external_id")
68
99
  pid = segment(person_external_id, "person_external_id")
69
100
  person = compact(
70
101
  "name" => name, "email" => email, "phone" => phone, "role" => role, "access" => access,
71
- "is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones
102
+ "is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones,
103
+ "custom_fields" => custom_fields, "clear" => clear
72
104
  )
73
105
  call("PUT", "/customers/#{cid}/people/#{pid}",
74
106
  body: { "person" => person }, idempotency_key: idempotency_key, timeout: timeout)
@@ -81,9 +113,13 @@ module Bfocus
81
113
  timeout: timeout)
82
114
  end
83
115
 
84
- # Retira o acesso da pessoa (ela continua no histórico).
116
+ # Retira o acesso da pessoa NESTE cliente (ela continua no histórico).
85
117
  # `DELETE /customers/{customer_external_id}/people/{person_external_id}`
86
- # @return [Hash] a pessoa, com `"access" => false`.
118
+ #
119
+ # O acesso é DO VÍNCULO: a mesma pessoa circula por vários clientes e tirar o acesso aqui
120
+ # não tira o dela nos outros.
121
+ # @return [Hash] a pessoa, com `"access" => false` e `"unlinked"` (`true` = ela segue ativa
122
+ # em outros clientes; `false` = era só deste e foi desligada).
87
123
  def delete(customer_external_id, person_external_id, idempotency_key: nil, timeout: nil)
88
124
  cid = segment(customer_external_id, "customer_external_id")
89
125
  pid = segment(person_external_id, "person_external_id")
@@ -211,6 +211,7 @@ module Bfocus
211
211
  human = nil
212
212
  request_id = nil
213
213
  validation = {}
214
+ data = {}
214
215
 
215
216
  if payload.is_a?(Hash)
216
217
  err = payload["error"]
@@ -222,6 +223,10 @@ module Bfocus
222
223
  end
223
224
  human = msg if msg.is_a?(String) && !msg.empty? && msg != code
224
225
  validation = payload["validation"] if payload["validation"].is_a?(Hash)
226
+ # `data` é o detalhe estruturado do erro (de quem é o contato já usado, o dono de um
227
+ # identificador…). A API também o repete em `validation`, mas quem lê o erro precisa
228
+ # alcançá-lo sem depender dessa duplicação.
229
+ data = payload["data"] if payload["data"].is_a?(Hash)
225
230
  rid = payload["request_id"]
226
231
  request_id = rid if rid.is_a?(String) && !rid.empty?
227
232
  elsif !text.strip.empty?
@@ -247,6 +252,7 @@ module Bfocus
247
252
  status: status,
248
253
  request_id: request_id,
249
254
  validation: validation,
255
+ data: data,
250
256
  retry_after: retry_after,
251
257
  required_scope: required_scope,
252
258
  body: payload.nil? ? present(text) : payload
@@ -3,5 +3,5 @@
3
3
  module Bfocus
4
4
  # Versão da gem. O `scripts/release-sdks.sh` do monorepo bumpa esta linha por regex e o
5
5
  # `bfocus.gemspec` lê daqui — mantenha o formato exato `VERSION = "X.Y.Z"`.
6
- VERSION = "0.2.0"
6
+ VERSION = "0.2.2"
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bfocus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Berni Software
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-19 00:00:00.000000000 Z
11
+ date: 2026-09-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Clientes, produtos, release notes, base de conhecimento e agentes de
14
14
  IA do bFocus. Zero dependências de runtime, novas tentativas e idempotência automáticas.