feat(swarm): outbound /swarm/lessons feed handler — substrate (#139)#155
Conversation
Pure handler module + 32 contract tests for the §10.6 outbound broadcast feed. The loader is injected so tests run with no DB or HTTP — first slice of issue #139, mirrors the #138 substrate / wiring split. Status mapping: 400 — malformed since (must be ISO8601 with timezone) or malformed limit (must be an integer; clamped if outside [1,200] but rejected if non-numeric / decimal / NaN). 503 — loader threw; operator-readable detail in the body. 200 — { lessons: Lesson[], next_since: string | null }. next_since = max(signed_at) + 1ms; null on empty page. Substrate filter contract documented on the loader: it MUST read FROM swarm_lessons_broadcast_eligible (mig 078 firebreak) AND apply local_weight >= 0.3 (mig 082 anti-echo-chamber). The handler deliberately does NOT re-filter — the substrate IS the contract. Privacy invariant: rows are relayed verbatim, never re-signed. Re-signing would let any node forge producer attestations for lessons it has merely seen — same trust break the proof handler's relay-guard prevents at §4.6. Out of scope for this PR (issue #139 pieces 2 + 3): active publish at REM B→A promotion (lesson_chain append + producer-side signing), and the self-broadcast safety CHECK / trigger. No HTTP wiring, no migration, no dashboard-server.mjs touch — wiring lands as a follow-up. Test plan - [x] `npm test` — 678 pass (was 646; this PR adds 32 contract tests) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the "Self-broadcast safety CHECK / trigger preventing a self-row from carrying a foreign-looking origin_node_id" follow-up listed as out-of-scope in PR #155 (issue #139, outbound /swarm/lessons feed handler substrate). Adds migration 085 with two BEFORE triggers that converge on a single invariant: a row whose id appears in lesson_chain (074) — definitionally a self-row — MUST carry origin_node_id equal to the local self identity. Both triggers funnel through a shared self_broadcast_assert_origin helper so the misattribution and identity-unbootstrapped error messages stay consistent regardless of insert order. The swarm_lessons trigger uses UPDATE OF origin_node_id (not blanket BEFORE UPDATE) so receiver-side signature_verified_at and lesson_tier promotions stay off the hot path. Foreign rows admitted via /swarm/lessons (PR #154 substrate) pass through unchecked since they have no lesson_chain entry — the wire-validator and §10.2 quarantine cover that surface. 17 new migration-085 contract tests pin every structural surface (helper signature, STABLE volatility, both trigger gates, IS DISTINCT FROM null handling, UPDATE OF clause). Migration discipline tests pin the absence of DROP, ALTER on prior tables, and re-definition of the §10.6 firebreak view from migration 078. Test plan: - npm run build — clean (strict TS) - npm test — 663 pass (baseline 646; +17 new) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sub-phases 4d (#119 → 09c64b8), 4e endpoint (#128 → 170d7cc), 4i.2 (#129 → 69dd87c), and 4i.3 (#131 → dc88488) all landed on main. The status table still showed them as 🟡 PR open — flip to ✅ on main. Also adds a 4j row for the self-broadcast safety triggers (migration 085, c4a545e) that landed via PR #156 as a follow-up to #155, and rewrites the prose paragraph to point at the active wiring PRs (#136 through #143) so a reader scanning §9 sees what comes next. Direct-to-main per the tick-prompt's docs/spec carve-out — pure documentation, no code or migration touched. Note: PR #151 (`agent/swarm-docs-status-refresh-2`) had the same table fix queued, but it was branched before c4a545e landed and would have reverted migration 085 + its test on merge. Refreshing main here means PR #151 now goes to MERGE-CONFLICT and Reed can close it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Peer audit — substrate is solid, two follow-up flags for the wiring PRRead the handler + tests and ran the contract suite locally on the PR branch ( What I verified
Edge cases I probed (all behave as the docstrings claim)
Two flags for the wiring PR — not blockers here
The exported Privacy invariant — verbatim relayThe shape-pin test (top-level keys = NetSubstrate is correct, well-tested, and matches the substrate-vs-wiring split that worked for |
…nly Vererbung+federation-transport are deferred Roadmap line 194 lumped "Schwarm + Vererbung + Föderation" as deferred, but §10 mechanics (anti-echo, contradicts gate, diversity, REM-self-audit, inbound POST /swarm/lessons admission) have been wired into active code on main since PRs #91, #121, #128, #131, #148, #154, #155. Issues #137 and #138 carry status snapshots from earlier ticks confirming end-to-end wiring against migrations 080 + 082. Splits item 5 into two distinct lines (Paarung/Vererbung — migrations.deferred 033–036, Cross-Host-Föderation — 038/041) and adds a clarifying paragraph that points to the still-active §10 substrate plus docs/waves.md for the cross-wave view. The closing paragraph now also names what specifically the single brain doesn't know (cross-host trust transport), instead of the catch-all "Schwarm/Föderation". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
First slice of issue #139 — pure handler module + 32 contract tests for the §10.6 outbound broadcast feed (
GET /swarm/lessons?since=…&limit=…). Mirrors the #138 / #154 substrate-vs-wiring split.mcp-server/src/swarm/endpoints/lesson-feed.ts— pure response builder. The loader is injected so contract tests run with no DB / HTTP.mcp-server/src/__tests__/lesson-feed.test.ts— 32 tests covering input validation, defaulting, clamping, cursor math, and shape-pin regression guards.Status mapping
since(must be ISO8601 with timezone) or malformedlimit(must be an integer; clamped silently if outside[1, 200], rejected if non-numeric / decimal / NaN).detailfield in the body.{ lessons: Lesson[], next_since: string | null }.next_since = max(signed_at) + 1ms;nullon empty page.Substrate filter contract
Documented on the loader signature: it MUST read
FROM swarm_lessons_broadcast_eligible(the §10.6 firebreak view from migration 078) AND applylocal_weight >= 0.3(the §10.4 anti-echo-chamber filter from migration 082). The handler deliberately does NOT re-filter — the substrate IS the contract; the integration test that verifies this lands with the wiring PR.Privacy invariant
Rows are relayed verbatim — the handler never re-signs. Re-signing would let any node forge producer attestations for lessons it has merely seen (the same trust break the proof handler's relay-guard prevents at §4.6).
Out of scope (issue #139 pieces 2 + 3)
lesson_chainappend + producer-side signing). Depends on feat(swarm): wire producer-side lesson_chain hook into record_lesson #136 producer hook.origin_node_id.dashboard-server.mjs— follow-up PR (mirror of feat(swarm): GET /swarm/lessons/{id}/proof endpoint — sub-phase 4e (#105) #128 → feat(swarm): wire GET /swarm/lessons/{id}/proof into dashboard-server (#105) #152).Test plan
npm test— 678 pass (baseline 646; this PR adds 32 tests)tsc --buildclean🤖 Generated with Claude Code