Skip to content

feat: event-driven intentions Phase 1 — subscriptions + on_fire actions + execute_directive #331

Description

@cmeans-claude-dev

Summary

Event-driven intentions — subscription primitive + on_fire action list on intentions. Item 5 in the revised pre-beta sequence (per pre-beta-plan-synthesis-2026-04-17). Spec is implementation-ready; design is stable.

Current intention flow fires on deliver_at timestamps only. Real triggers are often event-based: "first external beta user onboards," "claude-dev starts in mcp-clipboard repo," "sleep score drops below 70 three nights running." Using timestamps as proxies for events produces false-positive fires, silent misses, and placeholder-date decay.

Core insight: awareness needs an interrupt mechanism. Events are signals. Subscriptions are pre-registered handlers. The controller matches signals to subscribers and fires handlers when predicates match.

Architecture (three layers, "CPU interrupts" analogy)

  1. Event sources — edge providers and internal state changes write entries. They don't know who's listening.
  2. Interrupt controller — new component. Receives "entry written" signals, evaluates active subscriptions, fires matching handlers. Priority, masking, dispatch.
  3. Handlers — intentions with event triggers. Fire means state transition pending → fired, with the matching entry linked as cause, and execution of the intention's on_fire action list.

Phase 1 builds a narrow slice of all three.

Intention on_fire action list

Every intention gets an on_fire field: an array of actions to execute on fire. Default [{action: "surface_in_briefing"}] (preserves current behavior).

Phase 1 supported actions

  • surface_in_briefing — current behavior; intention appears in next briefing
  • execute_directive — invoke a directive in execute mode with parameter bindings

Deferred to Phase 2+ (needs REST + consent channel)

  • webhook — POST to configured URL using the outbound payload schema
  • email — send via configured SMTP
  • sms — send via configured SMS provider (paid tier)

All outbound actions will require creation-time consent (category external-call from the action-gating model) and user-configured endpoint allowlists.

execute_directive action semantics

When an intention's on_fire includes execute_directive:

  1. Target — directive entry (referenced by logical_key or entry_id)
  2. Parameters — JSON object matching the directive's parameters_schema (see feat(security): prompt-injection Phase 1 — directive entry type, scan_status, execute-mode retrieval #306)
  3. Fire-time context — awareness automatically merges the triggering entry_id and event metadata into the parameter binding before schema validation
  4. Creation-time authority — when the intention is created, the user pre-authorizes the intention-to-directive binding with the specific parameters and action categories (see action-gating sibling issue)

Phase 1 constraint: "queue for next AI session" model

Awareness does NOT autonomously invoke an AI to execute the directive. The directive is queued as a pending execution that surfaces in the next briefing in execute-mode. The human-connected AI session (claude-dev arriving in a repo, Chris opening claude.ai, etc.) picks it up and runs it.

This keeps the "silence is the product" model intact. Nothing runs without an AI session attached. Autonomous execution (awareness invokes an AI endpoint on its own) is Phase 2+ with careful consent architecture.

Concrete success case: claude-dev starts in mcp-clipboard. An event fires an intention whose on_fire is execute_directive(load-wip-context, repo={repo_name}). Directive queues. Claude-dev reads the briefing, sees the pending directive in execute-mode, runs it, context loaded. No autonomous AI invocation; just faster-than-manual context handoff.

Authority intersection

Directive runs with the intersection of (directive's standing authority) and (intention's pre-authorized invocation authority captured at creation). If the directive gains new authority later, existing intentions don't automatically benefit. Bind-at-creation.

Loop prevention

  • Directives invoked by intention fire cannot themselves cause another intention fire (causality depth ≤ 1)
  • Intention-triggered writes do NOT fire new subscriptions
  • Directives cannot create new intentions (Phase 1 coarse rule)

Revocation

If target directive is deleted or scan_status becomes flagged:

  • Intention auto-disabled
  • Loud surfacing in next briefing: "intention X disabled because directive Y is no longer runnable"

Phase 1 subscription model

In scope

  • New table: subscriptions (id, owner_id, predicate jsonb, action_type, action_payload jsonb, enabled, created_at, updated_at)
  • Predicate language: AND-only composition of source match, tag match (any-of), entry_type match. No OR, no NOT, no nesting, no time/spatial/fuzzy predicates.
  • Synchronous evaluation in-process, in the write path. Every remember / add_context / report_status / update_entry / remind call evaluates active subscriptions against the new entry after commit.
  • Action types in Phase 1: activate_intention(intention_id) only. Fires the linked intention (pending → fired), then executes the intention's on_fire actions (surface_in_briefing and/or execute_directive).
  • Single-owner scope. Subscriptions evaluate only against entries owned by the same user.
  • Edge-triggered only. Fires once when predicate matches a new entry. No level-triggered accumulators.
  • MCP tools: create_subscription, list_subscriptions, update_subscription, delete_subscription.

Out of scope (deferred)

  • Async evaluation via worker queue
  • OR composition and nested predicates
  • Cross-owner subscriptions (tied to sharing work)
  • Level-triggered and absence triggers
  • Time, spatial, and fuzzy predicates (require DSL, location provider, LLM inference)
  • Multiple direct action types at subscription level (beyond activate_intention)
  • Subscription masking and priority levels
  • Full fire loop detection (beyond the Phase 1 coarse rules above)
  • Backfill against existing entries at subscription creation time

Predicate format (forward-compatible with DSL)

{
  "source": "mcp-awareness-project",
  "tags_any": ["beta-user", "onboarding"],
  "entry_type": "note"
}

All three fields optional. Field presence means "must match." Tag match is array-overlap. Present fields ANDed. Absent fields unconstrained.

Forward-compatibility: predicate shape must be expressible as a strict subset of the Awareness DSL (dsl-social-knowledge-design). Migration from JSON predicates to DSL expressions must be mechanical, not a rewrite.

  • source → DSL SOURCE "X"
  • tags_any → DSL tag("X") OR tag("Y")
  • entry_type → DSL type(note) or equivalent

Breaking changes are cheap before launch, expensive after. Verify mapping before finalizing.

Firing semantics

  1. User writes an entry via any tool.
  2. After successful commit in the transaction, subscription evaluator runs synchronously in the same request.
  3. Evaluator queries subscriptions WHERE owner_id = :owner AND enabled = true.
  4. For each matching subscription, execute activate_intention(intention_id):
    • Update intention state pending → fired
    • Record triggering entry_id in intention data
    • Execute on_fire actions:
      • surface_in_briefing — no-op now; intention will appear in next briefing
      • execute_directive — merge fire-time context into parameter binding, validate against directive's parameter schema, queue the directive execution request for the next AI session's briefing
  5. Write action log via existing acted_on mechanism (subscription → triggering entry → fired intention → invoked directive when applicable).
  6. Return original write response.

Failure handling: subscription evaluation errors are logged; original write must still succeed.

Integration with consent/gating

Intentions with on_fire actions in gated categories require consent at creation time:

  • execute_directive → requires directive's own authorization to be valid and pre-authorized for this binding
  • webhook (deferred) → external-call category, requires user consent + allowlisted endpoint
  • email / sms (deferred) → external-call, requires consent

Fire-time: if pre-authorized binding exists, execute. If not, action is deferred to the next user session with a pending consent request (in-chat Ask in Phase 1; out-of-band channels once the sibling issue ships).

Kill switch and safety

User can disable all on_fire actions globally (or per-category) without affecting intention state otherwise. The backstop: if things go wrong, the user stops all automation immediately without needing to unwind subscriptions one by one.

Rate limits per intention and per destination (when webhook/email/sms ship) prevent runaway behavior even on pre-authorized bindings.

Schema sketch

CREATE TABLE subscriptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT,
    predicate JSONB NOT NULL,
    action_type TEXT NOT NULL CHECK (action_type IN ('activate_intention')),
    action_payload JSONB NOT NULL,
    enabled BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_subscriptions_owner_enabled ON subscriptions(owner_id) WHERE enabled = true;
CREATE INDEX idx_subscriptions_predicate_tags ON subscriptions USING GIN ((predicate -> 'tags_any'));

ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY subscriptions_owner_isolation ON subscriptions
    USING (owner_id = current_setting('app.current_user_id')::uuid);

Intention schema extended with:

  • on_fire JSONB array of action objects (default [{"action": "surface_in_briefing"}])
  • Pre-authorized bindings stored as part of on_fire payload for execute_directive actions

RLS enforces owner isolation. Application layer enforces predicate evaluation and directive authority intersection.

Success criteria

  • Chris creates intention "Load claude-dev context for mcp-clipboard" with subscription predicate matching a "claude-dev started" event and on_fire: execute_directive(load-wip-context, {repo: from_fire_context}). When the event fires, the directive queues and claude-dev picks it up on next briefing read.
  • Non-subscription intentions still fire on timestamps as before. No regression.
  • Cross-owner isolation maintained. Subscriptions on Chris's user never fire from other users' writes.
  • Failed directive invocation (revoked, scan-flagged) auto-disables the intention and surfaces the failure.

Implementation to-do (Claude Code flesh-out)

  • Module organization within existing codebase
  • Tool definitions for create_subscription, list_subscriptions, update_subscription, delete_subscription
  • Integration with all write paths (remember, add_context, report_status, update_entry, remind)
  • Intention state transition with on_fire execution
  • Directive invocation queue mechanism (how execute_directive requests surface in briefing)
  • Alembic migration for subscriptions table and intention on_fire field
  • Test scaffolding following existing patterns
  • Documentation updates
  • Naming decision: "subscription" vs "watcher" vs "trigger" vs "hook"

Out-of-scope reminders (do NOT creep)

  • OR composition
  • Time / spatial / fuzzy predicates
  • Webhooks, email, SMS on_fire actions (deferred to post-REST, post-consent-channel)
  • Async subscription evaluation
  • Cross-owner or shared-token firing
  • Autonomous AI invocation for directive execution (Phase 1 queues for next AI session)
  • Directive-to-directive chaining
  • Canvas UI for subscriptions

Sequencing

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1: highFix soon — impacting users or blocking near-term goalsenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions