Building a pack -- extend memQL with your own domain
A pack is the unit of product-specific extension in memQL: a bundle of Go
integration code plus a .memql DSL subtree that drops into the engine and
runs alongside the core domains. A product's DSL bundle plus its client is the
production reference consumer; this guide walks you through building one from
scratch, using the minimal reference pack at
examples/referencepack/ as the worked
example.
If you want the bare contract reference instead of a walkthrough -- the exact
PluginContext fields, the PluginFactory signature, the registration
primitives -- read Plugin SDK. This guide assumes that page as
background and shows how the pieces fit together.
Scope. A pack has two halves that load differently. The Go integration is compiled in via build tags -- linking its package into a binary runs its
init()registrations, and there is no runtime loading of the Go half, by design (see Build Tags). The.memqldomains load either the same way (embedded, as the reference pack below does) or at runtime from disk viaMEMQL_DSL_PATH-- a product-agnostic engine image mounts a product's DSL bundle at boot withRegisterTree(domain, os.DirFS(...)). This guide embeds; the sameRegisterTreecall backs both paths.
What a pack is, exactly
A pack is three registration primitives, all called from a build-tag-gated
init():
memql.RegisterPluginForContract(name, version, factory)-- registers your GoIntegrationProvider(the DSL-callable capability set) against the Plugin SDK contract version you built against. The loader rejects a pack whose declared version is incompatible with the core'smemql.PluginContractVersion, so a stale pack fails loudly at startup instead of silently mis-binding. (RegisterPluginstamps the current version implicitly; third-party packs SHOULD pin it explicitly withRegisterPluginForContract.)dsl.RegisterTree(domain, fs.FS)-- mounts your embedded.memqlsubtree underdomain/in the unified DSL tree. Namespace ownership is validated here.node.RegisterRoutingRule(rule)-- a cross-node event routing rule, required for any event that must cross a node boundary. The minimal reference pack does not cross nodes, so it skips this one. (Every cross-node event-bus pub/sub needs a routing rule or it silently dies in cluster mode.)
Everything else -- concepts, builtins, tools, automations, prompts, queries,
mutations, specs, shapes -- lives in the embedded .memql tree and rides in via
RegisterTree. The Go side is only the protocol-adapter capabilities the DSL
cannot express.
The three registration primitives
1. Register your Go integration (contract-versioned)
Your pack exposes Go-backed operations to the DSL through an
memql.IntegrationProvider: it returns a stable IntegrationName() and a list
of IntegrationCapability values, each a named handler. A capability named
composeGreeting on an integration named referencepack becomes callable from
the DSL under the FQN integration.referencepack.composeGreeting.
A memql.PluginFactory builds that provider from the live PluginContext:
func NewProvider(pctx memql.PluginContext) (memql.IntegrationProvider, error) { // pluck DB getters, providers, resolvers off pctx here (this minimal // pack needs none); return (nil, nil) to opt out when deps are missing. return &Provider{}, nil}You register it pinned to the contract version you built against:
memql.RegisterPluginForContract("referencepack", memql.PluginContractVersion, NewProvider)The app bootstrap (app.materializePlugins) calls
PluginRegistration.ValidateContract for every registered pack and aborts
startup -- fatal, with a descriptive error -- on any incompatibility.
memql.CheckPluginContractCompat(version) is the pure, unit-testable form of
that check.
Order gotcha (custom harnesses / tests). A pack's builtin resolves its Go capability by FQN (
integration.<name>.<capability>) at dispatch time, against the integration capabilities registered on the engine. Register the provider afterengine.Init-- registering before Init, then calling Init, leaves the capability out of the dispatch map and the builtin fails withunknown builtin executor "integration.<name>.<cap>". The normal app path is automatic:app.materializePluginsregisters every pack provider after the engine is initialized. You only have to think about this when wiring an engine by hand (e.g. an integration test). Seeexamples/referencepack/live_e2e_test.go.
2. Register your embedded DSL tree (namespace-owned)
Embed your .memql files and mount them under your domain:
//go:embed all:dslvar packFS embed.FS func Tree() fs.FS { sub, _ := fs.Sub(packFS, "dsl") return sub} dsl.RegisterTree("referencepack", Tree())After this, every DSL loader that walks dsl.Tree() sees your files under
referencepack/ alongside the core domains, and your concepts/tools/builtins
register into the same engine registries core uses.
Namespace ownership is validated at registration.
dsl.RegisterTree calls dsl.ValidatePackDomain(domain, coreDomains, existing)
before mounting, and panics on a violation:
- the domain must be non-empty and contain no
/; - it must not collide with a core embedded domain -- core domains are canonical and owned by memQL; a pack cannot shadow or extend one;
- it must not collide with another pack's already-registered domain -- two packs claiming the same namespace is ambiguous and rejected.
This is the namespace-ownership rule from the pack model
(Plugin SDK -> Pack model).
Pick a unique domain. The core domain set is read from the embedded tree's
top-level directories, so it always reflects the real //go:embed directive.
Partition scoping -- the canonical tenant dimension a pack scopes its data to
-- is a separate axis; see Partition scoping.
3. Register routing rules (only if you cross nodes)
If your pack emits an event on one node type that must be consumed on another,
register a routing rule with node.RegisterRoutingRule(...) -- otherwise the
event silently dies in cluster mode. The reference pack stays on one node, so it
omits this. The contract is documented in Plugin SDK.
Build-tag gating: how a pack is loaded (and how it is kept out)
A pack must run only in the binaries that should carry it. The mechanism is
Go build tags on the file that holds the pack's init():
//go:build mypack package mypack func init() { Register("mypack") // dsl.RegisterTree + memql.RegisterPluginForContract}The init() runs only when the binary is built with that tag, and the package
is anchored into the binary via a blank import in the app bootstrap. A node type
built without the tag never links the registration, so the pack never loads
there. This is exactly how a product gates a compiled-in Go integration to the
node types that should carry it. See Build Tags.
Keeping a pack out of production entirely. The reference pack demonstrates
the inverse: a pack that go build ./... compiles (so CI verifies it builds)
but that no production binary ever loads. The trick is twofold:
-
The pack package (
pack.go) is normal, untagged Go with no unconditionalinit()-- merely linking it in does not register it. Registration is the explicitRegister(domain)function, which tests call directly. -
A separate file
register_referencepack.gocarries//go:build referencepackand the onlyinit():go//go:build referencepackpackage referencepackfunc init() { Register(Domain) }This is the real build-tag-gated auto-register pattern a production pack uses -- but the
referencepacktag is never set in any production build, so theinit()never runs and the pack never auto-loads. Swapreferencepackfor your product tag and you have a production pack.
The takeaway: put the pack's init() in a build-tag-gated file, never in
the always-compiled package body. That single rule decides where a pack loads.
A guided tour of the reference pack
The reference pack at examples/referencepack/
is intentionally minimal but real -- it builds, loads into the engine
registries, and extends a core service, proven by Go tests under the default
go test ./.... Here is every file and what it demonstrates.
examples/referencepack/├── pack.go Go: embed + Domain/ContractVersion + Provider + NewProvider + Register├── register_referencepack.go Go: //go:build referencepack -> init() { Register(Domain) }├── reference_pack_test.go test: concept load + provider capability + contract gate (exported API)└── dsl/ ├── concepts.memql one concept (greeting, owned-tier ownerUserId) ├── builtins.memql one builtin backed by @executor("integration.referencepack.composeGreeting") ├── tools.memql one tool surfacing the builtin to the agent tool loop └── automations.memql one automation hooking the CORE v1:cognition:space node.created eventdsl/concepts.memql -- a single greeting concept. Its id assembles from
@version("1.0.0") + @namespace("referencepack") + the name greeting into
v1:referencepack:greeting. It carries an ownerUserId so it models the
owned authorization tier (per-row authz key) the same way core concepts do,
not just an empty schema.
dsl/builtins.memql -- a single referencePackComposeGreeting builtin whose
@executor("integration.referencepack.composeGreeting") names the pack's Go
capability. This is the DSL end of the wire; the Go Provider.Capabilities()
in pack.go is the other end. The FQN's middle segment
(referencepack) is the provider's IntegrationName(); the last segment
(composeGreeting) is the capability Name.
dsl/tools.memql -- a single referencePackGreet tool. Its
@handler(type="function", name="referencePackComposeGreeting") points at the
builtin by name, so an agent that calls the tool ultimately runs the pack's Go
handler. This is how a pack surfaces a capability into an agent's tool loop.
dsl/automations.memql -- a single automation that hooks a core service
event: @trigger(event="node.created", concept="v1:cognition:space", ...).
When the core engine creates a space row, this pack-owned automation fires --
the core has no knowledge of the pack. Its step calls the pack's own builtin via
a kind-prefixed call (builtin referencePackComposeGreeting ( userName: ownerUserId ) -- the owner bound via the automation's typed args { } contract, G5 #2367),
so it also exercises the pack's Go capability. The automation pulls the builtin
into scope with a file-top use referencepack.builtins.{ referencePackComposeGreeting }
import -- the standard cross-file dependency mechanism.
pack.go -- the Go core:
DomainandContractVersionconsts (the latter pinned tomemql.PluginContractVersionthe pack compiled against).Tree() fs.FS-- the//go:embed all:dslsubtree, re-rooted so the files appear directly (soRegisterTree(Domain, Tree())mountsconcepts.memqlatreferencepack/concepts.memql).Provider-- theIntegrationProvider, withIntegrationName()returningreferencepackandCapabilities()returning the onecomposeGreetingcapability whose handler builds a greeting node.NewProvider(pctx)-- thePluginFactory.Register(domain string)-- the single entry point that doesdsl.RegisterTree(domain, Tree())+memql.RegisterPluginForContract(domain, ContractVersion, NewProvider).domainis a parameter so a test can mount the same tree under a throwaway namespace.
register_referencepack.go -- the build-tag-gated init() (covered above).
How the tests prove load + extend
Two test files, both running under the default go test ./...:
examples/referencepack/reference_pack_test.go(externalpackage referencepack_test, exported API only):- mounts
Tree()under a unique throwaway domain viadsl.RegisterTree, witht.Cleanup(dsl.UnregisterTree); - runs
memql.LoadUnifiedConcepts(the loader the engine runs at boot) and assertsv1:referencepack:greetingis now inmemorynodes.DefaultRegistry()-- the pack extending the core concept registry; - builds
NewProviderwith a minimalPluginContextand asserts itsCapabilities()includecomposeGreetingwith a non-nil handler; - asserts
memql.CheckPluginContractCompat(ContractVersion) == niland that aPluginRegistration{...}.ValidateContract()passes.
- mounts
component/memql/reference_pack_load_test.go(in-packagepackage memql, for the unexportedToolRegistryconstructor):- mounts the pack's real
tools.memqland runsLoadUnifiedTools, assertingreferencePackGreetresolves in the engine's tool registry -- the pack extending the core tool surface; - loads the real
builtins.memqlviaLoadUnifiedBuiltinsand asserts the builtin registers; - runs
dslimports.Loadover the pack's wholedsl/tree, asserting every artifact (including the core-service-hook automation and itsuseimport) parses and resolves.
- mounts the pack's real
Together they prove the pack builds, loads into the engine registries alongside core, and extends both the concept and tool surfaces -- with no database.
Build your own pack: the checklist
- Create a package directory with a
dsl/subdir for your.memqlfiles. - Write your concept(s) with
@version+@namespace("yourdomain"); model authz with anownerUserId(owned tier) or the granted/admin/public pattern. - Write any builtins (
@executor("integration.yourdomain.<fn>")), tools, and automations. Cross-file deps go through file-topuseimports. - Implement an
IntegrationProviderwhoseIntegrationName()matches your@executormiddle segment and whoseCapabilities()back each builtin. - Write a
PluginFactory(NewProvider(pctx)). - Embed
dsl/and write aRegister(domain)that callsdsl.RegisterTree+memql.RegisterPluginForContract(+node.RegisterRoutingRuleif you cross nodes). - Put the auto-register
init()in a build-tag-gated file for your product tag, and anchor the package via a blank import in the app bootstrap. - Pick a unique domain -- it must not collide with a core domain or another pack.
- Verify with
go build ./...,go vet, and a load-test modeled on the reference pack's.
Production dogfood example: the deploy pack
The reference pack is the minimal teaching example. The deploy pack at
examples/deploypack/ (Epic 2 / #2095) is the
production-shaped sibling: it packages memQL's OWN deployment workflow as a pack,
dogfooding the model. Same primitives, but its capabilities are the REAL deploy
effects.
examples/deploypack/├── pack.go Go: Provider holding a deploycontrol.Executor + engine; 4 effect capabilities├── register_deploypack.go Go: //go:build deploypack -> init() { Register(Domain) }├── deploy_pack_test.go test: capability exposure + per-effect routing (exported API)└── dsl/ ├── builtins.memql 5 builtins -> @executor("integration.deploypack.{commitOverlay,argoSync,runPromote,recordBack,observeReconciledState}") ├── automations.memql two CDC automations on v1:cluster:deployment status └── logic.memql driveDeploymentInProgress (promote + transition) + recordReconciledState (Model A record-back)The pack also shows a pack hooking a core CDC event: dsl/automations.memql
triggers on graph.node.updated.v1:cluster:deployment and dsl/logic.memql
ports component/deploycontrol/deploy.go's imperative apply+transition into a
declarative chain -- when a deployment enters in_progress, fire runPromote
(the live azure effect) and transition the record to succeeded/failed on the
in-band outcome. A second automation (recordReconciledState, the Model A
record-back loop) observes the ArgoCD-reconciled state via
observeReconciledState and appends the observed per-node readiness back into
the deployment concept once the deploy reports succeeded. The logic imports the
pack's own effects (use deploypack.builtins.{ ... }) AND a core mutation
(use cluster.mutations.{ updateDeploymentStatus }) -- the standard
cross-namespace use mechanism.
The key difference from the reference pack: the deploy pack's Provider holds a
deploycontrol.Executor (the SAME side-effect boundary the Deploy Console
uses -- promote.sh / git / kubectl argo rollouts) plus an engine handle.
Its NewProvider(pctx) builds the Executor anchored at MEMQL_DEPLOY_REPO_ROOT
(mirroring app/integrations_deploy_control.go) and takes the engine from
pctx.Engine. The four capabilities are the deploy effects:
commitOverlay/argoSync->Executor.Git(Model A: author + commit the overlay, push so ArgoCD reconciles -- never a direct cluster apply).runPromote->Executor.RunPromote-- THE live azure deploy effect (scripts/release/promote.shvia the Argo Rollout), invoked through the same method the Deploy Console uses. Exposing it through the pack is additive; the imperative path is untouched until E2.5 thins it.recordBack-> engine mutations (updateDeploymentStatus+createDeploymentNodeSpec) -- Model A record-back: mirror the GitOps-reconciled state into the deployment concept.
This is the canonical example of a pack contributing effects backed by an existing Go side-effect boundary rather than a fresh capability. The E2.3 chained automations fire these effects on deployment status transitions; the pack is the substrate they call into.
See also
- Plugin SDK -- the contract reference (PluginContext, PluginFactory, primitives, contract version, load-time validation).
- Build Tags -- the node-type tag model that gates which binaries carry your pack.
- Partition scoping -- the canonical tenant dimension your pack scopes data to.