0.8.0-dev3: Subject taxonomy + DedupStore + ReflexContext (foundation)#3
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Third sub-step of the brain refactor. Pure refactor PR — locks in two things every later sub-step depends on:
ReflexContextNo 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 tosensory.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()extractorDedup
netcortex/contracts/dedup_store.py—DedupStoreProtocol (atomic check-and-record, TTL-bounded)netcortex/working/dedup/in_memory.py—InMemoryDedupStore: asyncio-safe, LRU-capped, lazy sweep, injectable clock for fast tests. Redis-backed store lands in 0.9.0.netcortex/working/package — working memory layer per brain mapHandler injection
netcortex.reflex.protocol.ReflexContext— frozen dataclass, optional fields, forward-compatible.ReflexRunnerowns one and threads it through everyhandle()call.ReflexHandler.handle(event)→ReflexHandler.handle(event, ctx)— breaking, pre-release only.Handler refactor
link_downsensory.snmp.trap.link_down.>sensory.link_down.>bgp_dropsensory.snmp.trap.bgp_backward_transition.>sensory.bgp_drop.>security_alert(renamed fromsecurity_webhook)sensory.meraki.webhook.security.>sensory.security_alert.>Each handler now constructs a
fact_keyfrom event class + canonical target and consultsctx.dedup_store. Duplicates returnoutcome=\"skipped\"with the dedup rationale (severity demoted toinfo— corroboration telemetry, not a second incident).Bus grammar fix
Both
InMemoryEventBusandNatsEventBushad a regex (^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$) that rejected real-world identifier characters —:(MACs),|(compound targets),/(interface names likeGi0/1). Widened to the actual NATS grammar (^[^.\s*>]+(\.[^.\s*>]+)*$). Existing rejection cases (wildcards, empty tokens, whitespace) still rejected.Test plan
CI auto-runs:
tests/working/dedup/(6 cases) + updatedtests/reflex/(5 new dedup cases on top of existing dispatch/runner tests)DedupStorecontract suite (9 cases parametrized over registered implementations),test_subjects.py(13 cases for builders/parser/vocabulary), updated existingEventBuscontract still green against widened grammarPre-push end-to-end smoke against
InMemoryEventBus+InMemoryDedupStore:logged+ 3skippedoutcomeslink_down(trap + webhook + poll) andbgp_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
Factrecord. Acceptable trade-off for 0.8.0 because flap detection needs working memory anyway.Updated roadmap
0.8.0-dev1NatsEventBus+ contract parametrization0.8.0-dev20.8.0-dev30.8.0-dev4link_downon real state changes; outcomes persist to Neo4j0.8.0-dev5WebhookReceiver+MerakiWebhookAdapter0.8.0-dev6ThousandEyesWebhookAdapter0.8.0-dev7TrapReceiver+ linkDown decoder0.8.0-dev8TelemetryReceiver(gNMI)0.8.0-dev90.8.0Made with Cursor