Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

feat(kora): KR-FEAT-SLACK-DM ST1 — inbound handler + identity check + JSONL log#119

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-SLACK-DM-ST1
May 22, 2026
Merged

feat(kora): KR-FEAT-SLACK-DM ST1 — inbound handler + identity check + JSONL log#119
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-SLACK-DM-ST1

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

First ST of Phase 2 Feature 5. Fills the no-op handler body in the Slack webhook router from KR-D-DAEMON ST3 (#104) with real Kora-side DM processing: identity filtering, operational-state gating, JSONL persistence, structured-log emit.

ST1 is INBOUND-only. ST2 adds the outbound `chat.postMessage` echo reply via a new SlackClient.

Bucket spec: `kora_docs/17_cc_bucket_prompts/KR-FEAT-SLACK-DM_kora_joshua_dm.md` (commit e734d61).

Base: `feature/phase2-upgrades` — NOT main.

New modules

  • `kora_cli/handlers/init.py` — new package for per-source handlers (distinct from `listeners/` which owns lifecycle + transport routing).
  • `kora_cli/handlers/slack_dm_handler.py` (~270 lines) — `SlackDMHandler.handle_event(payload)`. Returns `{"ok": True}` to Slack regardless of internal disposition.

Filter precedence

  1. State gate — `OperationalStateHolder.current.primary_state` is PAUSED or STOPPED → drop. (PrimaryState enum lowercase string values verified against `agent/operational_state.py:70-74` per KR-MCP-RUNTIME-SURFACE ST1 K-DG correction; `.current` is a `@property`.)
  2. Event type — only regular `message` (no subtype). `message_changed` / `app_mention` etc. → `filtered_subtype`.
  3. Channel type — only `im`. `channel` / `group` → `filtered_non_im`.
  4. Bot ID — if `event.bot_id` is set → `filtered_bot` (echo-loop defense).
  5. Identity — `event.user` must match `KORA_SLACK_JOSHUA_USER_ID` env. Mismatch → `filtered_non_joshua` (silently dropped; we do NOT echo to non-Joshua, per spec).

Fail-CLOSED: if `KORA_SLACK_JOSHUA_USER_ID` env is unset, ALL messages are dropped with `reason: "joshua_id_env_unset"` + WARN log. Operator must configure to enable any routing.

JSONL persistence

  • Path: `KORA_SLACK_DM_LOG_PATH` env override, else `get_kora_home() / "slack_dm_log.jsonl"` (honors `KORA_HOME` primary + legacy `HERMES_HOME` fallback via `kora_constants`).
  • Schema per line: `{received_at, channel_id, thread_ts, user_id, text, event_ts, handled_status, extra?, error?}`.
  • `handled_status` enum: `received` / `filtered_non_joshua` / `filtered_non_im` / `filtered_bot` / `filtered_subtype` / `dropped_paused` / `dropped_stopped` / `handler_error`.
  • Best-effort: OSError on write → WARN log + continue (never block the 200 to Slack).
  • SECURITY: signing secret + bot token NEVER touch JSONL. Handler never imports either env var. Test asserts the secret env value doesn't appear after a diverse event sequence.

Structured-log emit

`[kora.slack_dm.received]` log line on identified Joshua DMs ONLY. Same audit-seam pattern as KR-D-DAEMON ST3 webhook dead-letter + KR-MCP-RUNTIME-SURFACE ST2 tool-called audit — when substrate ships a `kora.slack_dm.received` chain-event vocab literal, the runtime extension is small (log emit is the stable seam).

Webhook router wire-in

`kora_cli/listeners/webhooks.py:_handle_slack` (+31 lines):

  • Routes verified `event_callback` payloads to `SlackDMHandler`
  • Belt-and-suspenders exception guard at the listener boundary (handler wraps its own `_handle_event_inner` too) — Slack ALWAYS gets a 200
  • Dead-letter on handler construction / exception path
  • URL-verification handshake unchanged (still inline)

§4 PM-opens — all defaults accepted (per chat ack)

Q Default Status
Q1 JSONL location single file at `get_kora_home() / "slack_dm_log.jsonl"`
Q2 PAUSED behavior drop (no buffer-replay)
Q3 Echo format `"Kora received: {text[:200]}"` ✓ locked for ST2
Q4 Dual signing-secret env note will land in ST3 runbook ✓ deferred

Tests (20 new, 197 total all passing)

  • Joshua DM received + logged + chain-event-log emit
  • Thread continuity (thread_ts in JSONL)
  • Non-Joshua filtered + NO chain-event emit
  • JOSHUA_USER_ID env unset → fail-CLOSED drop ALL
  • PAUSED state drops Joshua DMs
  • STOPPED state drops
  • READY + ACTIVE do NOT drop
  • Bot message filtered (echo-loop defense)
  • Subtype event filtered (`message_changed`)
  • Non-message event filtered (`app_mention`)
  • Non-IM channel_type filtered (`channel`, `group`)
  • Handler internal exception → `handler_error` + still 200
  • Malformed payload (missing event dict) survives
  • JSONL: one valid JSON per line, required fields present
  • `received_at` is round-trippable ISO 8601
  • SECURITY: signing-secret env value never in JSONL after diverse event sequence
  • Log path env override + default resolution via `get_kora_home()`

§5 ship checklist

  • Base `feature/phase2-upgrades`
  • Title `feat(kora): KR-FEAT-SLACK-DM STn — `
  • §4 PM-opens resolved (all defaults accepted)
  • Slack signing secret + bot token NEVER logged (asserted)
  • Identity check fail-CLOSED (non-Joshua → drop, no reply)
  • OperationalState gating respected (tested for all 5 enum values)
  • K-DG: `OperationalStateHolder.current` is a `@property`; PrimaryState lowercase string values verified
  • Tests pass locally (197/197 across full daemon + listener + handler + docker suite)

What's next

  • ST2 — outbound DM via NEW `kora_cli/clients/slack_client.py` with `chat.postMessage`; echo reply; retry-on-429 + 5xx; dead-letter on final failure; outbound entries in same JSONL.
  • ST3 — Slack app setup runbook at `kora_docs/15_status_and_roadmap/slack_app_setup_runbook.md`.

🤖 Generated with Claude Code

… JSONL log

First ST of Phase 2 Feature 5. Fills the no-op handler body in the
Slack webhook router from KR-D-DAEMON ST3 (#104) with real
Kora-side DM processing: identity filtering, operational-state
gating, JSONL persistence, structured-log emit.

ST1 is INBOUND-only. ST2 adds the outbound `chat.postMessage`
echo reply via a new SlackClient.

## New modules

- **`kora_cli/handlers/__init__.py`** — new package for per-source
  handlers (distinct from `listeners/` which owns lifecycle +
  transport routing).
- **`kora_cli/handlers/slack_dm_handler.py`** (~270 lines) —
  `SlackDMHandler.handle_event(payload)` returns `{"ok": True}` to
  Slack regardless of internal disposition (so Slack doesn't
  retry). Filter precedence:

  1. **State gate** — `OperationalStateHolder.current.primary_state`
     is PAUSED or STOPPED → drop. (PrimaryState enum values
     verified against `agent/operational_state.py:70-74` per
     KR-MCP-RUNTIME-SURFACE ST1 K-DG correction.)
  2. **Event type** — only regular `message` (no subtype).
     `message_changed` / `message_deleted` / `app_mention` etc.
     → filtered_subtype.
  3. **Channel type** — only `im`. `channel` / `group` → filtered_non_im.
  4. **Bot ID** — if `event.bot_id` is set → filtered_bot (echo-loop
     defense if Kora's own bot ever ends up in the chat).
  5. **Identity** — `event.user` must match `KORA_SLACK_JOSHUA_USER_ID`
     env. Mismatch → filtered_non_joshua (silently dropped; we do
     NOT echo back to non-Joshua, per spec).

  **Fail-CLOSED**: if `KORA_SLACK_JOSHUA_USER_ID` env is unset,
  ALL messages are dropped with `reason: "joshua_id_env_unset"`
  + a WARN log. Operator must configure the env to enable any
  routing.

## JSONL persistence

- Path: `${KORA_SLACK_DM_LOG_PATH}` env override, else
  `get_kora_home() / "slack_dm_log.jsonl"` (honors `KORA_HOME`
  primary + legacy `HERMES_HOME` fallback per `kora_constants`).
- Schema per line: `{received_at, channel_id, thread_ts, user_id,
  text, event_ts, handled_status, extra?, error?}`.
- `handled_status` enum: `received` / `filtered_non_joshua` /
  `filtered_non_im` / `filtered_bot` / `filtered_subtype` /
  `dropped_paused` / `dropped_stopped` / `handler_error`.
- Best-effort: OSError on write → WARN log + continue (never
  block the 200 to Slack).
- **SECURITY**: signing secret + bot token NEVER touch the JSONL.
  Handler never imports either env var. Test asserts the secret
  env value doesn't appear in the log file after a diverse
  sequence of events.

## Structured-log emit

`[kora.slack_dm.received]` log line on identified Joshua DMs.
Same audit-seam pattern as KR-D-DAEMON ST3 webhook dead-letter +
KR-MCP-RUNTIME-SURFACE ST2 tool-called audit — when substrate
ships a `kora.slack_dm.received` chain-event vocab literal, the
runtime extension is small (the log emit is the stable seam).

Non-Joshua events do NOT emit the chain-event log — only
identified-as-Joshua + state-passes-gate.

## Webhook router wire-in

`kora_cli/listeners/webhooks.py:_handle_slack` (+31 lines):
- Routes verified `event_callback` payloads to `SlackDMHandler`
- Belt-and-suspenders exception guard at the listener boundary
  (the handler wraps its own `_handle_event_inner` too) — Slack
  ALWAYS gets a 200, retries are aggressive + expensive
- Dead-letter on handler construction / exception path
- URL-verification handshake unchanged (still inline)

## §4 PM-opens — all PM defaults accepted (per chat ack)

- **Q1** JSONL location: single file at
  `get_kora_home() / "slack_dm_log.jsonl"`. Per-day rotation is
  operator-managed (logrotate / fluent-bit / etc.). ✓
- **Q2** PAUSED behavior: drop (no buffer-replay). ✓
- **Q3** Echo format `"Kora received: {text[:200]}"`: locked for ST2. ✓
- **Q4** Dual signing-secret env note: will land in ST3 runbook. ✓

## Tests (20 new, 197 total all passing)

**`test_slack_dm_handler.py`** (20 tests):
- Joshua DM received + logged + chain-event-log emit
- Thread continuity (thread_ts in JSONL)
- Non-Joshua filtered + NO chain-event emit
- JOSHUA_USER_ID env unset → fail-CLOSED drop ALL
- PAUSED state drops Joshua DMs
- STOPPED state drops
- READY + ACTIVE states DO NOT drop
- Bot message filtered (echo-loop defense)
- Subtype event filtered
- Non-message event (app_mention) filtered
- Non-IM channel_type filtered (`channel`, `group`)
- Handler internal exception → handler_error + still 200
- Malformed payload (missing event dict) survives
- JSONL: one valid JSON per line, required fields present
- `received_at` is round-trippable ISO 8601
- **SECURITY**: signing-secret env value never in JSONL after
  diverse event sequence (5 payloads exercising every filter)
- Log path env override + default resolution via `get_kora_home()`

## §5 ship checklist

- [x] Base `feature/phase2-upgrades`
- [x] Title `feat(kora): KR-FEAT-SLACK-DM STn — <scope>`
- [x] §4 PM-opens resolved (all defaults accepted)
- [x] Slack signing secret + bot token NEVER logged (asserted)
- [x] Identity check fail-CLOSED (non-Joshua → drop, no reply)
- [x] OperationalState gating respected (test for PAUSED + STOPPED + READY + ACTIVE)
- [x] K-DG: `OperationalStateHolder.current` is a `@property`
      (not method) per KR-MCP-RUNTIME-SURFACE ST1 correction;
      PrimaryState lowercase string values verified.
- [x] Tests pass locally (**197/197** across full daemon + listener +
      handler + docker suite)

## What's next

**ST2** — outbound DM via NEW `kora_cli/clients/slack_client.py`
with `chat.postMessage`; echo reply `"Kora received: {text[:200]}"`;
retry-on-429 (respect retry-after) + 1 retry on 5xx; dead-letter on
final failure; outbound entries in same JSONL file. AI-driven
response generation lands in `KR-FEAT-SLACK-DM-AI` follow-on.

**ST3** — `kora_docs/15_status_and_roadmap/slack_app_setup_runbook.md`
covering Slack app creation, OAuth scopes, Event Subscriptions
wire-up, Doppler secret setup, smoke test, troubleshooting +
the dual signing-secret env transition note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit ee225b4 into feature/phase2-upgrades May 22, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-FEAT-SLACK-DM-ST1 branch May 22, 2026 16:34
rafe-walker added a commit that referenced this pull request May 22, 2026
Phase 2 Feature 5 frontend. Pairs with CC#3 KR-FEAT-SLACK-DM (#119).

- Backend /api/slack-dm/recent stub (4 messages spanning happy path + filtered_non_joshua).
- SlackDMPanel.tsx (stats + filter pills + newest-first timeline + expandable rows).
- Dashboard card #9 (MessageCircle icon) + nav + 15 backend tests.

4-layer security contract (extending standard 3-layer with a 4th walk-payload guard for Slack-specific tokens): no raw U... Slack user IDs + channel_id stub shape + plain-text rendering (with dangerouslySetInnerHTML ban) + Slack token/signing-secret walk-payload sweep.

Layout: 9 cards = clean 3+3+3 with lg:grid-cols-3 — landed the symmetry sweet spot, no orphans.

219/219 admin-panel tests pass across 20 suites; tsc + vite clean.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant