Skip to content

0.8.0-dev3: Subject taxonomy + DedupStore + ReflexContext (foundation)#3

Merged
stevenca merged 1 commit into
mainfrom
feat/0.8.0-dev3-taxonomy-dedup
Jun 1, 2026
Merged

0.8.0-dev3: Subject taxonomy + DedupStore + ReflexContext (foundation)#3
stevenca merged 1 commit into
mainfrom
feat/0.8.0-dev3-taxonomy-dedup

Conversation

@stevenca
Copy link
Copy Markdown
Owner

@stevenca stevenca commented Jun 1, 2026

Summary

Third sub-step of the brain refactor. Pure refactor PR — locks in two things every later sub-step depends on:

  1. NATS subject taxonomy — event-class-first so one handler can subscribe to all sources of a thing
  2. DedupStore contract + in-memory implementation, threaded through handlers via the new ReflexContext

No new publishers, no production behavior change. First publisher lands in 0.8.0-dev4. The PR is intentionally a foundation so reviewers can vet the contracts in isolation before code starts depending on them at scale.

Why now

Conversation about "same physical event arriving via webhook + trap + poll" revealed dev2's subject taxonomy (sensory.<modality>.<source>.<event>.<target>) was modality-first — sensory.snmp.trap.link_down.> can't catch Meraki webhook or gNMI sources of link-down without per-source handler registrations. Event-class-first (sensory.<event_class>.<source>.<target>) lets one handler subscribe to sensory.link_down.> and react to every source uniformly. This is the right taxonomy to lock in before publishers depend on it.

What lands

Subject taxonomy

  • docs/architecture/subjects.md — authoritative spec (top-level namespaces, event-class vocabulary, source-token registry, target-part shape, dedup model, versioning rules, worked subscription examples)
  • netcortex/contracts/subjects.py — machine-readable companion: SENSORY_EVENT_CLASSES (closed vocabulary, 11 classes), SENSORY_SOURCES (16 source tokens), sensory_subject() validated builder, parse_sensory_subject() extractor

Dedup

  • netcortex/contracts/dedup_store.pyDedupStore Protocol (atomic check-and-record, TTL-bounded)
  • netcortex/working/dedup/in_memory.pyInMemoryDedupStore: asyncio-safe, LRU-capped, lazy sweep, injectable clock for fast tests. Redis-backed store lands in 0.9.0.
  • New top-level netcortex/working/ package — working memory layer per brain map

Handler injection

  • netcortex.reflex.protocol.ReflexContext — frozen dataclass, optional fields, forward-compatible. ReflexRunner owns one and threads it through every handle() call.
  • ReflexHandler.handle(event)ReflexHandler.handle(event, ctx) — breaking, pre-release only.

Handler refactor

Handler Old pattern New pattern Window
link_down sensory.snmp.trap.link_down.> sensory.link_down.> 60s
bgp_drop sensory.snmp.trap.bgp_backward_transition.> sensory.bgp_drop.> 60s
security_alert (renamed from security_webhook) sensory.meraki.webhook.security.> sensory.security_alert.> 300s

Each handler now constructs a fact_key from event class + canonical target and consults ctx.dedup_store. Duplicates return outcome=\"skipped\" with the dedup rationale (severity demoted to info — corroboration telemetry, not a second incident).

Bus grammar fix

Both InMemoryEventBus and NatsEventBus had a regex (^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$) that rejected real-world identifier characters — : (MACs), | (compound targets), / (interface names like Gi0/1). Widened to the actual NATS grammar (^[^.\s*>]+(\.[^.\s*>]+)*$). Existing rejection cases (wildcards, empty tokens, whitespace) still rejected.

Test plan

CI auto-runs:

  • Unit tests — picks up new tests/working/dedup/ (6 cases) + updated tests/reflex/ (5 new dedup cases on top of existing dispatch/runner tests)
  • Contract tests — adds DedupStore contract suite (9 cases parametrized over registered implementations), test_subjects.py (13 cases for builders/parser/vocabulary), updated existing EventBus contract still green against widened grammar
  • Contract tests against real NATS — confirms widened regex matches actual NATS behavior, not just our in-memory mock
  • Lint / mypy / golden / replay / security / SBOM (unchanged)

Pre-push end-to-end smoke against InMemoryEventBus + InMemoryDedupStore:

  • 6 publishes across 3 unique facts → 3 logged + 3 skipped outcomes
  • Correct severity demotion on skipped outcomes
  • Cross-modality dedup confirmed for link_down (trap + webhook + poll) and bgp_drop (trap + gnmi)

Known limitation, called out in subjects.md

A real flap (down/up/down within one window) collapses to a single fact. The fusion stage in 0.9.0 tracks state transitions explicitly via the working-memory Fact record. Acceptable trade-off for 0.8.0 because flap detection needs working memory anyway.

Updated roadmap

# Branch What lands Status
1 0.8.0-dev1 NATS + NatsEventBus + contract parametrization ✅ merged
2 0.8.0-dev2 Reflex skeleton + 3 idle handlers ✅ merged
3 0.8.0-dev3 Subject taxonomy + DedupStore + ReflexContext this PR
4 0.8.0-dev4 First publisher — ingest delta emission lights up link_down on real state changes; outcomes persist to Neo4j next
5 0.8.0-dev5 WebhookReceiver + MerakiWebhookAdapter
6 0.8.0-dev6 ThousandEyesWebhookAdapter
7 0.8.0-dev7 TrapReceiver + linkDown decoder
8 0.8.0-dev8 TelemetryReceiver (gNMI)
9 0.8.0-dev9 Module renames + legacy cutover
tag 0.8.0 Smoke on microk8s

Made with Cursor

…exContext

Third sub-step of the brain refactor. Locks in two things every later
sub-step depends on: the NATS SUBJECT TAXONOMY and the DEDUP CONTRACT.
No new publishers and no production behavior change yet — first publisher
lands in 0.8.0-dev4. This PR is a pure refactor so the foundations can
be reviewed in isolation.

SUBJECT TAXONOMY

  docs/architecture/subjects.md is the authoritative spec. Machine-
  readable constants in netcortex/contracts/subjects.py:
    * SENSORY_EVENT_CLASSES — closed vocabulary (link_down, link_up,
      bgp_drop, bgp_up, device_reboot, device_unreachable,
      device_reachable, security_alert, config_change, topology_change,
      route_advertisement_change). Adding a class requires doc +
      constant change in the same PR.
    * SENSORY_SOURCES — <modality>_<provenance> tokens (snmp_trap,
      snmp_poll, meraki_webhook, gnmi_dialout, ...).
    * sensory_subject(class, source, *target_parts) — validated builder.
    * parse_sensory_subject(subject) — handlers use this to derive the
      fact_key.

  Why event-class-first: sensory.<event_class>.<source>.<target> lets
  one handler subscribe to sensory.link_down.> and catch every source.
  The earlier modality-first ordering forced per-source subscriptions
  and made same-event-multi-source dedup awkward.

DEDUPSTORE PROTOCOL + INMEMORYDEDUPSTORE

  netcortex/contracts/dedup_store.py defines atomic check-and-record.
  netcortex/working/dedup/in_memory.py ships the only 0.8.0 impl:
    * Asyncio-safe (lock-protected mutations).
    * TTL-bounded with lazy expired-entry sweep (bounded budget).
    * Size-bounded with LRU eviction.
    * Injectable clock for fast deterministic unit tests.

  Redis-backed impl lands in 0.9.0 alongside working memory. Contract
  tests (9 cases) parametrize over every registered implementation;
  Redis only needs a factory + registry row to gain full coverage.

REFLEXCONTEXT (HANDLER DEPENDENCY INJECTION)

  netcortex.reflex.protocol.ReflexContext is the runtime dependency
  bag every handler receives on handle(event, ctx). Frozen dataclass,
  all fields optional, new resources added by appending fields so old
  handlers are unaffected.
    * ctx.dedup_store: DedupStore | None — 0.8.0
    * future: semantic_memory, working_memory, policy_engine

  ReflexRunner owns one ReflexContext (default-constructed if not
  supplied) and threads it through every dispatch.

HANDLER REFACTOR — new patterns + dedup logic

  Handler          | Old pattern (dev2)                              | New pattern (dev3)              | Window
  -----------------|-------------------------------------------------|---------------------------------|--------
  link_down        | sensory.snmp.trap.link_down.>                   | sensory.link_down.>             | 60s
  bgp_drop         | sensory.snmp.trap.bgp_backward_transition.>     | sensory.bgp_drop.>              | 60s
  security_alert   | sensory.meraki.webhook.security.>               | sensory.security_alert.>        | 300s

  Renamed security_webhook → security_alert because the new handler
  is source-agnostic (Meraki today, Cisco AMP / future SIEM tomorrow).
  File moved to security_alert.py via git mv (rename preserved).

  Each handler now constructs fact_key = "<event_class>|<target>"
  (plus event_type for security_alert) and consults ctx.dedup_store
  when present. Duplicates return outcome="skipped" with rationale;
  first arrivals return outcome="logged" as before. Severity demoted
  to info on skipped outcomes — they are corroboration telemetry,
  not a second incident.

  Known limitation in 0.8.0: real flap (down/up/down within one
  window) collapses to a single fact. Fusion stage in 0.9.0 will
  track state transitions explicitly. See subjects.md.

EVENT BUS GRAMMAR FIX

  Both InMemoryEventBus and NatsEventBus had an overly-strict subject
  validator (^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$) that rejected
  real-world identifier characters (':' in MACs, '|' in compound
  targets, '/' in interface names). Widened to match the actual NATS
  grammar: ^[^.\s*>]+(\.[^.\s*>]+)*$ — any printable except '.',
  whitespace, '*', '>'. Existing rejection cases (wildcards, empty
  tokens, whitespace) still rejected.

TESTS

  * tests/contracts/dedup_store/test_dedup_store_contract.py — 9 cases
    parametrized over every store impl: atomicity-under-concurrency,
    TTL expiry, empty-key rejection, non-positive-TTL rejection,
    close idempotency, use-after-close raises.
  * tests/contracts/test_subjects.py — 13 cases for builders, parser,
    vocabulary integrity.
  * tests/working/dedup/test_in_memory.py — 6 cases for in-memory
    store specifics (LRU eviction, lazy sweep, fake clock, ctor
    validation, close clears state).
  * tests/reflex/test_handlers.py — new patterns + 5 new dedup cases
    (cross-source link_down, different-target independence,
    missing-target skip-dedup, Meraki retry dedup, distinct-event-
    type-no-dedup, trap+gnmi dedup for bgp_drop).
  * tests/reflex/test_runner.py — new signature + 2 new cases
    (default-context wiring, explicit-context threading).
  * tests/reflex/test_registry.py — updated stub signature.

  End-to-end smoke: 6 publishes across 3 unique facts → 3 logged + 3
  skipped outcomes. Same result expected against real NATS via the
  contract suite.

BREAKING — pre-release only

  * ReflexHandler.handle(event) → ReflexHandler.handle(event, ctx).
    The three first-party handlers updated; no external consumers.
  * Handler id security_webhook → security_alert. dev2 release never
    persisted these to anywhere stable, so rename is cost-free.

NOT YET WIRED

  * No publishers — pollers still call correlator + writeback directly.
    First dual-write publisher lands in 0.8.0-dev4.
  * Outcomes are logged only — Neo4j :ReflexEvent persistence + NetBox
    journal mirror also land in 0.8.0-dev4.

Co-authored-by: Cursor <cursoragent@cursor.com>
@stevenca stevenca merged commit b85b896 into main Jun 1, 2026
8 checks passed
@stevenca stevenca deleted the feat/0.8.0-dev3-taxonomy-dedup branch June 1, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant