Skip to main content

Advanced Patterns

9 min read

Definition Meta

Add optional metadata to any definition for better debugging, richer devtools, and self-documenting systems.

Meta is supported on all 7 definition types: modules, facts, events, constraints, resolvers, effects, and derivations.


Why Meta?

Directive's inspect(), explain(), and devtools show definition IDs like auth::needsLogin or fetchUser. Meta lets you attach human-readable context – labels, descriptions, categories, and colors – so these surfaces are immediately useful without checking source code.

Meta is purely informational. It is never read during reconciliation – zero runtime overhead.


Adding Meta

Every definition type (constraints, resolvers, effects, derivations) accepts an optional meta field.

Modules

const authModule = createModule("auth", {
  schema: { /* ... */ },
  meta: {
    label: "Authentication",
    description: "Handles login, tokens, and session management",
    category: "auth",
  },
  // ... constraints, resolvers, etc.
});

Module meta surfaces in inspect().modules and system.meta.module(id). Useful for architecture diagrams, module health dashboards, and onboarding documentation.

Facts (Schema Fields)

schema: {
  facts: {
    score: t.number().meta({ label: "Player Score", category: "data" }),
    email: t.string().meta({ label: "Email", tags: ["pii"] }).nullable(),
    plain: t.number(),  // no meta – works as before
  },
}

The .meta() method chains with all schema builder methods (.nullable(), .min(), .maxLength(), etc.). Surfaces in inspect().facts and system.meta.fact(key). Use tags: ["pii"] to mark fields for compliance scanning.

Events

events: {
  // Function form (no meta) – unchanged
  increment: (facts) => { facts.count += 1; },

  // Object form with meta
  reset: {
    handler: (facts) => { facts.count = 0; },
    meta: { label: "Reset Counter", category: "ui" },
  },
},

Event meta surfaces in inspect().events and system.meta.event(name).

Constraints

constraints: {
  needsLogin: {
    when: (facts) => !facts.user,
    require: { type: "LOGIN" },
    meta: {
      label: "Requires Authentication",
      description: "User is not logged in",
      category: "auth",
      color: "#f59e0b",
    },
  },
},

Resolvers

resolvers: {
  login: {
    requirement: "LOGIN",
    resolve: async (req, context) => { /* ... */ },
    meta: {
      label: "OAuth Login Flow",
      description: "Exchanges authorization code for access token",
    },
  },
},

Effects

effects: {
  logPhase: {
    run: (facts) => console.log(facts.phase),
    meta: { label: "Phase Logger", category: "logging" },
  },
},

Derivations

Derivations support two forms. The existing function form works exactly as before. The new object form uses compute instead of a bare function:

derive: {
  // Function form (no meta) – unchanged
  isReady: (facts) => facts.status === "ready",

  // Object form with meta
  displayName: {
    compute: (facts) => `${facts.firstName} ${facts.lastName}`,
    meta: { label: "Display Name", description: "Full name for UI" },
  },
},

The DefinitionMeta Type

import type { DefinitionMeta } from "@directive-run/core";

interface DefinitionMeta {
  label?: string;       // Human-readable name
  description?: string; // Longer explanation
  category?: string;    // Grouping key (e.g., "auth", "data", "ui")
  color?: string;       // CSS hex color (e.g., "#f59e0b")
  tags?: string[];      // Multi-dimensional labels for filtering
  [key: string]: unknown; // Extensible for plugins
}

You don't need to import DefinitionMeta to use it – inline objects are inferred correctly. The import is useful when building reusable helpers or plugins.

Suggested Categories

CategoryUse for
authAuthentication and authorization
dataData fetching and persistence
uiUI state and display logic
loggingLogging and analytics
lifecycleSystem lifecycle management

Categories are freeform strings – custom categories work, these are conventions for consistency.

Tags

Use tags for multi-dimensional filtering. A constraint can be both "auth" and "critical":

constraints: {
  needsLogin: {
    when: (facts) => !facts.user,
    require: { type: "LOGIN" },
    meta: {
      label: "Requires Auth",
      category: "auth",
      tags: ["critical", "security"],
    },
  },
},

Tags are separate from category – use category for primary grouping, tags for cross-cutting concerns.


Where Meta Surfaces

system.meta (O(1) queries)

Query meta directly by definition ID without building a full inspection snapshot:

// O(1) lookups
system.meta.module("auth")?.label;             // "Authentication"
system.meta.constraint("needsLogin")?.label;   // "Requires Auth"
system.meta.resolver("login")?.description;    // "Exchanges code for token"
system.meta.effect("log")?.category;           // "logging"
system.meta.derivation("displayName")?.tags;   // ["ui", "critical"]

// Returns undefined for unknown IDs
system.meta.constraint("nonexistent");         // undefined

// Bulk queries across all definition types
system.meta.byTag("pii");                       // MetaMatch[] – tagged fields, and derivations that read them
system.meta.byTag("pii", { kind: "fact" });     // just the facts

// One definition, no walk. O(1) for a fact.
system.meta.carriesTag("fact", "email", "pii"); // true

// Told when the answer can have moved, so nothing has to poll.
const stop = system.meta.subscribe(["pii"], rebuild, { immediate: true });

Each MetaMatch contains { kind, id, meta, tagOrigin } where kind is "module" | "fact" | "event" | "constraint" | "resolver" | "effect" | "derivation". tagOrigin is "inherited" on a derivation matched through the tag inheritance below and "authored" when the tag was written on the definition itself – always present, so you never have to read meaning into a missing field.

carriesTag returns boolean | undefined, and the third state is the point. undefined means the runtime could not answer – a derivation body threw, or the definition is unknown. Reading that as "no tag, nothing to redact" is the failure this surface exists to prevent, so default it to the safe side: screen, redact, escalate.

Use byTag("pii") for a compliance inventory, carriesTag for the per-write question.

Use system.meta in plugins, custom devtools, and AI tooling for efficient meta access.


Tags travel down the derivation graph

A tag on a fact is a claim about the value. A derivation carries that value forward – often unchanged – so it carries the claim:

schema: {
  facts: { email: t.string().meta({ tags: ["pii"] }) },
  derivations: { domain: t.string() },
},
derive: { domain: (facts) => facts.email.split("@")[1] },
system.meta.byTag("pii");
// [ { kind: "fact",       id: "email",  tagOrigin: "authored"  },
//   { kind: "derivation", id: "domain", tagOrigin: "inherited" } ]

system.meta.derivation("domain")?.inheritedTags;   // ["pii"]

inheritedTags is the difference, not the union – a tag the author wrote is still reported under tags. Inheritance is transitive: a derivation reading a derivation reading a tagged fact inherits too.

Saying where the claim stops

Some derivations are the point at which the claim no longer holds – a hash, a bucket, a count, a redaction:

derive: {
  bucket: {
    compute: (facts) => hash(facts.email) % 16,
    meta: { tagBoundary: true },
  },
},

Because that is a statement about the value, it holds downstream too: a derivation reading bucket is not walked through to bucket's inputs. It is a separate key rather than an empty tags: [] so a derivation can be sanitized and tagged something unrelated at the same time.

What it can and cannot tell you

Inheritance follows what a derivation actually read on its last computation – the same tracking that lets derivations work without a deps array. A body that branches records the branch the current state takes:

derive: { shown: (facts) => (facts.consented ? facts.email : "") },

While consented is true, shown inherits pii. When it flips, shown stops reading email and stops inheriting.

Read byTag("pii") as "every value carrying PII in the state the system is in", not "every value that ever could". For the second question you want the source, not the runtime.

If you are upgrading from before 1.27.0

Two results grew. Anything acting on byTag() – a redactor, an audit filter, a compliance sweep – now covers derivations it did not before. And system.meta.derivation(id) returns { inheritedTags: [...] } where it used to return undefined for a derivation with no authored meta, so if (system.meta.derivation(id)) no longer means "did anyone annotate this". Compare against ?.label or ?.tags instead.

If you are upgrading from 1.29.x

The shape changed and there are no aliases. MetaMatch.type is now kind; via?: "inherited" is now tagOrigin, always present with both values named; meta: { inheritsTags: false } is now meta: { tagBoundary: true } – note the polarity flip. byCategory() and revision() are gone: use byTag(tag, { kind }) and subscribe().

If you were polling revision() to keep a cache current, you probably do not need the cache at all. carriesTag is O(1) for a fact and cannot go stale.

inspect()

system.inspect() includes meta on all seven definition types:

const inspection = system.inspect();

for (const c of inspection.constraints) {
  console.log(c.id, c.meta?.label ?? c.id);
}

for (const r of inspection.resolverDefs) {
  console.log(r.id, r.meta?.label ?? r.id);
}

for (const e of inspection.effects) {
  console.log(e.id, e.meta?.label ?? e.id);
}

for (const d of inspection.derivations) {
  console.log(d.id, d.meta?.label ?? d.id);
}

// Modules too
for (const m of inspection.modules) {
  console.log(m.id, m.meta?.label ?? m.id);
}

explain()

system.explain() uses meta.label instead of the raw constraint ID and includes meta.description when present:

Without meta:

Requirement "LOGIN" (id: req-1)
├─ Produced by constraint: needsLogin
├─ Constraint priority: 0

With meta:

Requirement "LOGIN" (id: req-1)
├─ Produced by constraint: Requires Authentication
├─ Constraint priority: 0
├─ Description: User is not logged in

Devtools

The devtools plugin reads meta from inspect() and uses it for:

  • Node labels in the constraint/resolver graph
  • Color coding by category
  • Tooltips with descriptions

Trace Enrichment

When trace: true is enabled, every trace entry automatically carries meta inline on its sub-arrays:

const sys = createSystem({ module: mod, trace: true });
sys.start();

// After reconciliation...
const entry = sys.trace![0];
entry.constraintsHit[0].meta?.label;     // "Requires Auth"
entry.resolversStarted[0].meta?.label;   // "OAuth Login Flow"
entry.factChanges[0].meta?.label;        // "Player Score"
entry.effectsRun[0].meta?.label;         // "Phase Logger"
entry.derivationsRecomputed[0].meta?.label; // "Display Name"

Meta flows with the causal event – trace viewers show human-readable labels without secondary lookups.


AI Agent Context

The @directive-run/ai package can inject system meta into LLM prompts automatically:

import { createAgentOrchestrator } from "@directive-run/ai";

const orchestrator = createAgentOrchestrator({
  runner,
  metaContext: true, // Inject system meta into agent instructions
});

Or build context manually with toAIContext() and formatSystemMeta():

import { toAIContext } from "@directive-run/ai";

const context = toAIContext(system);
// Returns formatted markdown with modules, constraints, resolvers,
// facts, events, effects, derivations – only annotated definitions

The output is token-efficient – sections with no annotated definitions are omitted entirely.


Dynamic Definitions

Meta works with dynamically registered definitions:

system.constraints.register("dynamicCheck", {
  when: (facts) => facts.score < 0,
  require: { type: "FIX_SCORE" },
  meta: { label: "Score Validator", category: "data" },
});

Security

Meta objects are frozen at registration time using Object.create(null) + Object.freeze. This prevents:

  • Prototype pollution (no __proto__ chain)
  • Mutation after registration
  • Accidental shared state between definitions

Production Bundles

Meta values are string literals that survive minification and ship in production bundles. This is by design – meta powers production features like AI agent reasoning, compliance scanning (tags: ["pii"]), and observability dashboards.

Avoid putting sensitive information in meta fields:

  • Internal API paths (e.g., /api/internal/auth/refresh)
  • Employee names or personal information
  • Sensitive business rules you don't want visible in client bundles

Use generic labels instead: "Auth Check" rather than "Refreshes JWT via /api/internal/auth/refresh".


Next Steps

Previous
Error Boundaries

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management