[Trabalho de longa duração, entrega assinada]

Jobs assíncronos

Coloca trabalho de longa duração em fila com `POST /v1/jobs`, recebe um `id` e depois faz polling em `GET /v1/jobs/{id}` ou recebe um webhook assinado com HMAC ao concluir. Os kinds inicialmente suportados são `intelligence.enrich.bulk`, `export.bulk`, `import.bulk` e `graph.compute`. Cada transição de estado leva o mesmo `trace_id` de G2 para correlação ponta a ponta.

Jobs assíncronos

Colocar um job em fila

`POST /v1/jobs` exige o scope `argus:jobs:write` num bearer token. O corpo deve incluir `kind` (um dos kinds suportados) e `payload`. Os campos opcionais são `callback_url` (URL https para a qual a plataforma assinará e fará POST ao concluir) e `secrecy_level` (por defeito `standard`; o job herda o valor e nunca o eleva durante a execução). Em caso de sucesso, a resposta é 201 com `{ id, kind, status, created_at, trace_id }`. O job arranca em `queued` e o `trace_id` é o mesmo valor que flui pelos cabeçalhos de resposta de G2, permitindo correlacionar a colocação em fila com o trabalho a jusante.

Ciclo de vida e estados

Um job percorre uma máquina de estados estrita. Os workers reclamam linhas em fila com `SELECT ... FOR UPDATE SKIP LOCKED`, garantindo que cada job é entregue a um único worker de cada vez. Durante a execução, os workers fazem heartbeat a cada 30 segundos e atualizam `progress_pct`. Um reaper repõe na fila os jobs em execução cujo heartbeat tem mais de 2 minutos, evitando que um worker em falha bloqueie um job. Os estados terminais (`succeeded`, `failed`, `cancelled`) são escritos exatamente uma vez.

Idempotência

Envia `Idempotency-Key: <uuid>` (UUID v4 recomendado) em `POST /v1/jobs` para tornar a chamada repetível com segurança. Dentro de uma janela de 24 horas por tenant, repetições com a mesma key devolvem 200 com o mesmo `id` e corpo do 201 original, mesmo que a requisição original altere campos do payload. Sem o cabeçalho, Argus não dedupica; uma tentativa de rede pode colocar em fila duas vezes. As keys são scopadas por tenant, dois tenants podem usar o mesmo UUID sem conflito.

Polling ou webhooks

Escolhe um modelo por requisição. Polling: omite `callback_url` e chama `GET /v1/jobs/{id}` até o estado sair de `queued`/`running`, depois chama `GET /v1/jobs/{id}/result` uma vez para o URL assinado. Webhooks: inclui `callback_url` e a plataforma assinará e fará POST do evento de conclusão para esse URL. O polling é o caminho mais simples para batches iniciados por ti; os webhooks são a escolha certa quando um sistema externo precisa do resultado em segundos após a conclusão. Ambos os modos usam o mesmo registo e o mesmo `trace_id`.

Payload de webhook e verificação de assinatura

Ao concluir, Argus faz POST do corpo JSON canónico para o teu `callback_url` com os cabeçalhos `Content-Type: application/json`, `X-Argus-Trace-ID` (o mesmo trace_id de G2), `X-Argus-Job-ID` e `X-Argus-Signature: sha256=<hex>`. A assinatura é HMAC-SHA256 sobre o corpo usando o teu segredo partilhado registado. Os recetores DEVEM verificar a assinatura contra os bytes brutos do corpo antes de agir sobre o evento. Usa uma comparação de tempo constante (`hmac.compare_digest` em Python, `crypto.timingSafeEqual` em Node.js) para evitar ataques de tempo. Argus retenta entregas falhadas com backoff exponencial até 24 horas.

Cancelamento

`DELETE /v1/jobs/{id}` pede o cancelamento e exige o scope `argus:jobs:write`. Um job `queued` ou `running` devolve 204 e transita para `cancelled` (o worker observa a flag no próximo limite de heartbeat). Um job já `cancelled` devolve 200 (replay idempotente). Um job que já alcançou um estado terminal de sucesso (`succeeded` ou `failed`) devolve 409: trabalho concluído não pode ser revertido. Se um webhook estava registado, não é entregue webhook de conclusão para um job cancelado.

Catálogo de kinds

Quatro kinds são entregues no primeiro release. `intelligence.enrich.bulk` enriquece uma lista de profile IDs contra conectores parceiros. `export.bulk` produz exports scopados ao tenant (CSV, JSONL, FHIR Bundle) e escreve o resultado no R2; o endpoint de resultado devolve um URL assinado de curta duração. `import.bulk` ingere um bundle fornecido por parceiro e devolve resultados por linha. `graph.compute` executa uma consulta pré-computada no graph operacional e devolve o resultado materializado. Kinds futuros são aditivos; kinds existentes mantêm a sua forma de payload sob semver.

Isolamento por tenant

Cada consulta à tabela jobs inclui `tenant_id` E `organization_id` do bearer token. Procuras cruzadas devolvem 404 (nunca 403), pelo que a existência de um job noutro tenant nunca é revelada. O registo herda `secrecy_level` da requisição e as transições de ciclo de vida nunca o elevam. Cada mudança de estado escreve uma entrada de auditoria com utilizador, organização, tenant, ação (`jobs.enqueue`, `jobs.start`, `jobs.complete`, `jobs.fail`, `jobs.cancel`, `jobs.read`, `jobs.list`), `resource_id = id`, nível de segredo e metadados `{ kind, status, progress_pct, trace_id }`.

Ciclo de vida e estados

queued | running | succeeded | failed | cancelled

Colocar um job em fila

# Enqueue an async job
curl -X POST https://api.knogin.com/v1/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8b9d8a2e-4a8c-4a7e-8a2e-4a8c4a7e8a2e" \
  -d '{
    "kind": "intelligence.enrich.bulk",
    "payload": { "profile_ids": ["p1", "p2"] },
    "callback_url": "https://partner.example.com/argus-webhook",
    "secrecy_level": "standard"
  }'

# 201 Created
# {
#   "id": "01HFXY...",
#   "kind": "intelligence.enrich.bulk",
#   "status": "queued",
#   "created_at": "2026-05-08T12:00:00Z",
#   "trace_id": "0af7651916cd43dd8448eb211c80319c"
# }

Estado, resultado e payload de webhook

# GET /v1/jobs/{id} while running
{
  "id": "01HFXY...",
  "kind": "intelligence.enrich.bulk",
  "status": "running",
  "progress_pct": 42,
  "created_at": "2026-05-08T12:00:00Z",
  "updated_at": "2026-05-08T12:00:30Z",
  "completed_at": null,
  "error": null,
  "trace_id": "0af7651916cd43dd8448eb211c80319c"
}

# GET /v1/jobs/{id}/result after success
{
  "id": "01HFXY...",
  "result_ref": "r2://argus-jobs/results/01HFXY.json",
  "signed_url": "https://r2-presigned-url..."
}

# GET /v1/jobs?kind=intelligence.enrich.bulk&status=running&limit=50
{
  "jobs": [ /* job objects */ ],
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiLi4uIn0=",
  "total_count": 1234
}

Entrega de webhook

# Argus -> partner.example.com/argus-webhook
POST /argus-webhook HTTP/1.1
Host: partner.example.com
Content-Type: application/json
X-Argus-Trace-ID: 0af7651916cd43dd8448eb211c80319c
X-Argus-Job-ID: 01HFXY...
X-Argus-Signature: sha256=<hex of HMAC-SHA256(canonical_json_body, partner_secret)>

{
  "id": "01HFXY...",
  "kind": "intelligence.enrich.bulk",
  "status": "succeeded",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "result_ref": "r2://argus-jobs/results/01HFXY.json"
}

Verificar a assinatura do webhook

// Node.js (Express): verify X-Argus-Signature on the raw body
import crypto from 'node:crypto';

function verifyArgusSignature(rawBody, headerValue, sharedSecret) {
  const expectedHex = crypto
    .createHmac('sha256', sharedSecret)
    .update(rawBody)
    .digest('hex');
  const expected = Buffer.from(`sha256=${expectedHex}`, 'utf8');
  const provided = Buffer.from(headerValue ?? '', 'utf8');
  if (provided.length !== expected.length) return false;
  return crypto.timingSafeEqual(provided, expected);
}
# Python (Flask): canonical-body verification
import hmac, hashlib, json

def verify_argus_signature(raw_body: bytes, header_value: str, shared_secret: bytes) -> bool:
    expected = "sha256=" + hmac.new(shared_secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_value or "")

# Argus computes the signature over the canonical JSON body it sent:
# canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
# sig = hmac.new(secret, canonical, hashlib.sha256).hexdigest()
# header = f"sha256={sig}"

Pronto para integrar jobs assíncronos?

Abre a referência API para o contrato público ou contacta a Knogin se precisares de ajuda a definir um kind à medida.