openvibers/openvibe.actor Commits
Add gated Actor work fabric and transactional delivery ports (#394)
Changes
nnew file mode 100644@@@ -0,0 +1,39 @@++# Actor work fabric (S4)++++`ACTOR_WORK_FABRIC=1` enables the backend. It is off by default. Each agent starts with `observe` policy, empty allowed lists and zero budget. There are no UI changes. Configured Sources take over matching events from the legacy Chat/Mail intake, so an event cannot launch through both paths. Unconfigured events retain the existing path.++++## Transaction and integration boundary++++`server/fabric/engine.js` has synchronous commands. `service.js` locks one agent's database aggregate, runs one command, then commits its state, source cursors and stored work together. A thrown validation error rolls everything back. Model and network calls run outside the transaction. Commands for different agents use different row locks. This initial implementation stores an agent's history as one JSONB aggregate: it favors atomicity and auditability over high-volume throughput; archival/partitioning is future work before high-volume rollout.++++`agent.trigger@1`, `agent.ticket@1` and `agent.decision@1` validate the durable domain records. Internal delivery leases, subscriptions and disposition metadata are separate from those wire records. The existing `server/task-view/tickets.js` is a different read model and is not replaced.++++An owner uses their `actor.agent.manage` credential:++++- `GET /api/v1/fabric/:agent` reads state and revision.++- `POST /api/v1/fabric/:agent/commands/configure` stores `{sources, policy}`.++- Commands `start`, `decide`, `attach`, `stop`, `end`, `subscribe`, `unsubscribe`, `handBack`, `read`, `reply`, `schedule`, `cancelSchedule`, `tick`, `claim`, `settle`, `delivered` expose the engine ports. Unknown commands and foreign agents are refused. Source intake is not a public command.++++Source configuration maps instance ids to `{kind, enabled, self, scopes, receive_all}`. Chat additionally takes `mentions` (names without @); Mail takes `mail_agent`. The service stamps `owner_subject` from the authenticated owner, ignoring caller-supplied identities. Scope names are `global`, `channel:<id>`, `dm:<id>`, `inbox:<id>`, or configured time scope names. DMs must include both the bound agent identity and the owner. Mail events must name the owner's configured inbox and agent. Mail reads the original through the existing authorized Mail read client before entering the transaction, checks the server-stamped owner, inbox and sender, and groups replies by the References root. Chat room reply chains form Threads; separate top-level mentions do not share ownership. Own messages and events outside scope are dropped before domain intake. DM Events contain identifiers rather than text: the Trigger preserves these facts with a read pointer; workers must read the original through their authorized Chat connection.++++Policies contain `mode` (`off|observe|auto`), `rules` (`id`, `enabled`, `instruction`), `budget` (`usd`, `tokens`, `wall_minutes`) and `sources`. Each Source policy has `allowed`, `bots` and an `admission` table keyed by scope, with `listed_unaddressed`, `unlisted_addressed` and `unlisted_unaddressed` cells. The first and last allow `inferred|ignore`; unlisted addressed also allows `explicit`. No identities are mapped between Sources.++++## Routing and workers++++A `start` command batches pending Triggers on one Thread. While that evaluation runs, new events form its next batch. Explicit candidates produce `route` work; inferred candidates produce `assess` work. The router only routes; it cannot replace a worker-owned follow-up with new work. It may stop a Ticket on an explicitly authorized person's message.++++The assessor returns `decision`, `scope`, `direction` and `rule_assessments`. Direction contains `implementation_requested`, `target_basis` (`specified|existing-standard|undetermined`), `requires_design_decision` and `evidence` entries `{trigger_id, quote}`. Every quote must occur in the original Trigger and every enabled rule must be assessed exactly once. Inferred acceptance needs settled direction and a matched rule. Observe records `ignore` with `would_accept`, with no launch or reply. The `ignored_from_card` shortcut can only ignore, with a reason. A missing/failed assessment should return `unavailable`; delivery starts the timeout, and three delivered expiries end unavailable after two retries.++++Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver later events directly through `input` work without changing authority. Human own-thread messages stay with their workers until all owners hand them back, once each with a reason. `context` subscriptions neither own messages nor resume ended Tickets. `attach` requires the message to explicitly name the Ticket. A worker cannot unsubscribe from origin or Source-bound Threads. `read` is called only after a step consumed the input and a report/turn end followed; ending releases unread messages. Full session-level read accounting and report/stage integration are S6.++++`end` ends a worker round; it does not withdraw authority. A person's follow-up can resume it after current-policy revalidation, while bots cannot. `end` with `person:true` is the owner's manual stop. `stop` checks the originating human Trigger's explicit gate strength. Stopped Tickets never resume. Policy changes discard pending evaluations and notify affected live workers without cancelling their mandates.++++## Stored work++++Adapters claim `route`, `assess`, `launch`, `input`, `end` or `reply` work, perform it after the claim commits, then settle using the claim token. `createFabric().drain(owner, agent, handlers)` provides this boundary for in-process adapters. Handlers receive a Ticket-bound worker port: it cannot change policy, accept work, claim a human stop, or create an authoritative subscription. No model/executor adapter is enabled automatically by the feature flag: routing/assessment/worker integrations consume these ports. A rollout needs authorized adapters connected before turning Sources on; otherwise work remains durably queued, and no model calls or spend occur.++++Each adapter must honor the stable idempotency key. Transport is at-least-once; a crash after sending and before settlement retries the same key. Expiring leases recover interrupted sends; stale lease tokens cannot settle newer claims. Retries start at 2 seconds and double to 60 seconds (replies: 5 to 300 seconds). Earlier work blocks later work in the same conversation. Other conversations proceed independently. Replies wait while their Source is off and drop only when its destination is no longer listened to or cannot receive. Drops retain their reason and time and cannot be requeued with the same key. Resume launches include a recovery instruction to inspect existing work before repeating outside actions.++++Time belongs to `sources.js`, not assessment. A schedule is `{id, source, scope, due, text, interval_ms?}`; timestamps are epoch milliseconds. A quiet condition adds `{quiet_thread, since_trigger}`. The Source cancels it if that Thread changes before its due time. Time produces immutable system Triggers; admission requires `clock` in the Source's bot list and the scope in `receive_all`. Time never grants explicit authority. `tick` checks due conditions, expires evaluations and starts pending batches; it never sleeps or calls a model.++++Account export/deletion includes the fabric aggregate. The expand-only migration adds no production writes during development. Tests name the corresponding rules from reference docs 001–003; `ov test test/fabric.test.js test/fabric-store.test.js` exercises policy, intake, persistence, ownership, routing and delivery without external services.nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- S4: an agent is the transaction boundary. Commands run synchronously on its locked aggregate;++-- source cursors, immutable events, mandates and the ordered outbox commit together.++-- Separate from actor_task_view tickets: those are a read model for existing runtime tasks.++CREATE TABLE actor_work_fabric (++ agent_id TEXT PRIMARY KEY REFERENCES actor_agents(id) ON DELETE CASCADE,++ owner TEXT NOT NULL,++ revision BIGINT NOT NULL DEFAULT 0,++ state JSONB NOT NULL,++ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()++);++CREATE INDEX actor_work_fabric_owner ON actor_work_fabric(owner);@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0","pg": "^8.23.0",@@@ -1679,9 +1679,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.373.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",-- "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",++ "version": "0.384.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0","pg": "^8.23.0",@@@ -165,6 +165,12 @@ async function createApp(opts = {}) {// An agent answers the mail sent to its own address (track HO6): the consumer below takes the event, the engine// reports each run's end back here, and the approvals decide() asks it to send an approved draft.const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media, tips };++ const fabricMail = require('./mail/client').createMailClient({ config, fetchImpl: opts.mailFetch || fetchImpl, tokens: opts.mailTokens || null });++ ctx.fabric = require('./fabric/service').createFabric({ s, enabled: !!config.fabric?.enabled, readMail: async (args) => {++ const out = await fabricMail.getMessage(args);++ if (!out.ok) throw new Error('Fabric Mail Source read unavailable');++ return out.data;++ } });ctx.day = require('./day').createDay(ctx);engine.setDay(ctx.day);ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, triggers, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });@@@ -274,12 +280,16 @@ async function createApp(opts = {}) {// chat.message.created goes to chat/asks.js (ask Actor from chat); anything else is acknowledged and ignored.app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log,// A chat message asks Actor a question; anything else may wake a wait (plan A2).-- onEvent: (ev) => (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev)++ onEvent: async (ev) => {++ const intake = await ctx.fabric.onEvent(ev);++ if (intake.length) return 'fabric';++ return (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev)// The agent's steps (design 2026-11-07): every mode publishes agent.step.* with the person's subject.: String(ev.event_type || '').startsWith('agent.step.') ? timeline.onEvent(ctx.s, ev)// A letter at an agent's own address (track HO6): that agent answers it, with the mail as data.: ev.event_type === 'mail.agent_inbox.received' ? ctx.agentMail.onEvent(ev)-- : waits.onEvent(ev)) }));++ : waits.onEvent(ev));++ } }));// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());@@@ -317,6 +327,7 @@ async function createApp(opts = {}) {// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));app.use('/api/v1/resources', resourceIndex.router(ctx));++ app.use('/api/v1/fabric', require('./fabric/api').createRoutes(ctx));app.use('/api/v1', createApi(ctx));// A2A (stage V2-11): another agent hands Actor a task. The card is /.well-known/agent.json, below.app.use(createA2APages(ctx));@@@ -28,6 +28,7 @@ function load(env = process.env) {return {service: 'actor',++ fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },port,host: env.HOST || '127.0.0.1',nodeEnv,nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++const express = require('express');++const { asyncRouter } = require('../http/router');++const contracts = require('openvibe-contracts');++function createRoutes(ctx) {++ const r = asyncRouter();++ r.use(express.json({ limit: '64kb' }));++ r.use(ctx.principal.middleware);++ r.use(ctx.principal.requireCapability('actor.agent.manage'));++ r.use((req, res, next) => {++ if (!ctx.fabric.enabled) return res.sendStatus(404);++ if (req.principal?.kind !== 'user') return res.sendStatus(403);++ res.set('Cache-Control', 'no-store'); next();++ });++ const run = fn => async (req, res, next) => {++ try { res.json(await fn(req)); }++ catch (err) { if (err.status) return contracts.http.sendProblem(res, err.status, 'actor.fabric.refused', { detail: err.message, ctx: req.ov }); next(err); }++ };++ r.get('/:agent', run(req => ctx.fabric.snapshot(req.principal.requester, req.params.agent)));++ r.post('/:agent/commands/:command', run(req => ctx.fabric.command(req.principal.requester, req.params.agent, req.params.command, req.body)));++ return r;++}++module.exports = { createRoutes };nnew file mode 100644@@@ -0,0 +1,288 @@++'use strict';++const { policy, gate, revalidate, assessment, assert, text } = require('./policy');++const copy = structuredClone;++const own = (map, key) => Object.hasOwn(map, key) ? map[key] : undefined;++const emptyDirection = () => ({ implementation_requested: false, target_basis: 'undetermined', requires_design_decision: false, evidence: [] });++function initial() {++ return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0 };++}++// All methods are synchronous. The repository loads and locks an agent's aggregate, invokes exactly one command,++// then commits its state and outbox together. No model, timer, promise or network port is reachable here.++class Engine {++ constructor(state, { now = Date.now(), validate = () => {} } = {}) { this.s = state; this.now = now; this.validate = validate; }++ id(kind) { return `${kind}_${++this.s.seq}`; }++ thread(key) { assert(text(key) && !['__proto__', 'constructor', 'prototype'].includes(key), 'Invalid Thread'); return this.s.threads[key] ||= { pending: [], subscriptions: {}, evaluation: null, history: [] }; }++ queue(t) {++ const th = this.thread(t.threads[0]);++ if (!this.owners(t.id).length && !th.pending.includes(t.id) && !th.evaluation?.ids.includes(t.id) && !Object.hasOwn(this.s.accepted, t.id)) th.pending.push(t.id);++ }++ owners(id) { return Object.entries(this.s.meta).filter(([, m]) => m.owned.includes(id)).map(([k]) => k); }++ enqueue(kind, key, payload, conversation = key) {++ const old = this.s.work.find(w => w.key === key);++ if (old) return old;++ const w = { id: this.id('work'), key, kind, payload: copy(payload), conversation, state: 'pending', attempts: 0, due: this.now };++ this.s.work.push(w); return w;++ }++ configure({ sources, policy: next }) {++ if (sources) {++ for (const [id, s] of Object.entries(sources)) {++ assert(/^[a-zA-Z0-9_-]{1,80}$/.test(id) && !['__proto__', 'constructor', 'prototype'].includes(id) && ['chat', 'mail', 'time'].includes(s.kind) && Array.isArray(s.scopes) && s.scopes.every(text) && Array.isArray(s.receive_all || []) && (s.receive_all || []).every(x => s.scopes.includes(x)), 'Invalid Source configuration');++ assert(text(s.self), 'Source needs its own bot identity');++ assert(typeof s.enabled === 'boolean', 'Source needs enabled flag');++ }++ this.s.sources = copy(sources);++ }++ if (!next) return this.s.policy;++ const old = this.s.policy, p = policy(next);++ if (old.version === p.version) return p;++ this.s.policy = p;++ for (const th of Object.values(this.s.threads)) if (th.evaluation) {++ const ids = th.evaluation.ids;++ this.finish(th, 'unavailable', { reason: 'policy_changed' });++ for (const id of ids) this.queue(this.s.triggers[id]);++ }++ for (const [id, k] of Object.entries(this.s.tickets)) {++ if (k.state !== 'open') continue;++ const m = k.mandate;++ if (old.mode !== p.mode || JSON.stringify(old.sources[m.actor.source]) !== JSON.stringify(p.sources[m.actor.source]) || m.matched_rules.some(r => JSON.stringify(old.rules.find(x => x.id === r)) !== JSON.stringify(p.rules.find(x => x.id === r)))) {++ this.enqueue('input', `policy:${id}:${p.version}`, { ticket_id: id, policy: p, previous_version: old.version }, `worker:${id}`);++ }++ }++ return p;++ }++ receive(t) {++ this.validate('agent.trigger', t);++ assert([t.id, ...t.threads].every(id => text(id) && !['__proto__', 'constructor', 'prototype'].includes(id)), 'Invalid event or thread id');++ assert(t.actor.source === t.source, 'Identity is Source-local');++ const source = this.s.sources[t.source];++ if (!source || !source.enabled || !source.scopes.includes(t.source_scope) || t.actor.id === source.self) return { status: 'filtered' };++ if (Object.hasOwn(this.s.triggers, t.id)) return { status: 'duplicate' };++ this.s.triggers[t.id] = copy(t);++ const reached = new Map();++ for (const key of t.threads) {++ const th = this.thread(key);++ th.last_trigger_id = t.id;++ th.triggers ||= []; th.triggers.push(t.id);++ th.triggers.sort((a, b) => Date.parse(this.s.triggers[a].occurred_at) - Date.parse(this.s.triggers[b].occurred_at) || a.localeCompare(b));++ for (const [id, sub] of Object.entries(th.subscriptions)) reached.set(id, (reached.get(id) || false) || sub !== 'context');++ // Correlation is recorded for reads only; it is never traversed by fan-out.++ th.correlated = [...new Set([...(th.correlated || []), ...t.threads.filter(x => x !== key)])];++ }++ for (const [id, own] of reached) {++ const k = this.s.tickets[id], m = this.s.meta[id];++ if (m.stopped || m.revoked) continue;++ if (k.state === 'ended') {++ if (t.actor.kind !== 'person' || !own) continue;++ if (!revalidate(k, m, this.s.triggers[k.trigger_id], this.s.policy, this.s.sources)) { m.revoked = true; continue; }++ k.state = 'open'; k.stage = 'working/queued';++ this.enqueue('launch', `resume:${id}:${t.id}`, { ticket: k, recovery: 'Check existing work before repeating any outside action.' }, `worker:${id}`);++ }++ if (t.actor.kind === 'person' && own) m.owned.push(t.id);++ this.enqueue('input', `input:${id}:${t.id}`, { ticket_id: id, trigger: t, context_only: !own }, `worker:${id}`);++ }++ this.queue(t);++ return { status: 'stored', owners: this.owners(t.id) };++ }++ start({ thread }) {++ const th = this.thread(thread);++ if (th.evaluation) return copy(th.evaluation);++ const ids = th.pending.filter(id => !this.owners(id).length && !Object.hasOwn(this.s.accepted, id));++ ids.sort((a, b) => Date.parse(this.s.triggers[a].occurred_at) - Date.parse(this.s.triggers[b].occurred_at) || a.localeCompare(b));++ if (!ids.length) { th.pending = []; return null; }++ const candidates = ids.map(id => {++ const t = this.s.triggers[id];++ const follow = Object.values(this.s.tickets).some(k => k.threads.includes(thread) && this.thread(thread).subscriptions[k.id] !== 'context');++ return { id, result: gate(t, this.s.policy, this.s.sources[t.source], follow) };++ }).filter(x => x.result);++ candidates.sort((a, b) => Number(b.result.strength === 'explicit') - Number(a.result.strength === 'explicit'));++ const best = candidates[0];++ th.pending = [];++ th.evaluation = { id: this.id('eval'), ids, primary: best?.id || ids[0], strength: best?.result.strength || 'inferred', basis: best?.result.basis, policy_version: this.s.policy.version, attempts: th.expiries || 0 };++ if (!best) { this.finish(th, 'ignore', { reason: 'not_admitted' }); return null; }++ const e = copy(th.evaluation);++ // Explicit candidates go to the deterministic routing port, never the assessor.++ this.enqueue(e.strength === 'explicit' ? 'route' : 'assess', `candidate:${e.id}`, { evaluation: e, triggers: ids.map(id => this.s.triggers[id]), tickets: Object.values(this.s.tickets).filter(k => k.threads.includes(thread)), policy: this.s.policy, handbacks: Object.values(this.s.meta).flatMap(m => m.handbacks).filter(h => ids.includes(h.trigger_id)) }, `router:${thread}`);++ return e;++ }++ finish(th, decision, extra = {}) {++ const e = th.evaluation;++ const d = { id: this.id('decision'), trigger_ids: e.ids, policy_version: e.policy_version, decision, strength: e.strength, direction: emptyDirection(), rule_assessments: [], ...extra };++ this.validate('agent.decision', d);++ this.s.decisions.push(d); th.history.push(d.id); th.evaluation = null;++ for (const w of this.s.work) if (w.key === `candidate:${e.id}` && w.state !== 'done') { w.state = 'dropped'; w.reason = 'evaluation_finished'; w.finished_at = this.now; }++ return d;++ }++ decide({ evaluation, decision, scope, assessment: a, reason, ignored_from_card = false }) {++ const th = Object.values(this.s.threads).find(t => t.evaluation?.id === evaluation);++ assert(th, 'Evaluation is no longer current');++ const e = th.evaluation;++ assert(e.policy_version === this.s.policy.version, 'Policy changed');++ assert(['accept', 'ignore', 'unavailable'].includes(decision), 'Invalid routing decision');++ let matched = [], audit = { reason: reason || decision };++ if (e.strength === 'inferred' && !ignored_from_card && decision !== 'unavailable') {++ const result = assessment(a || {}, this.s.policy, e.ids.map(id => this.s.triggers[id]));++ matched = result.matched;++ decision = a.decision === 'unavailable' ? 'unavailable' : result.accepted ? 'accept' : 'ignore';++ scope = a.scope;++ audit = { ...audit, direction: a.direction, rule_assessments: a.rule_assessments };++ } else if (ignored_from_card) {++ assert(e.strength === 'inferred' && decision === 'ignore' && text(reason), 'Card shortcut only ignores inferred work with a reason');++ audit.ignored_from_card = true;++ } else assert(!a, 'Explicit candidates do not use assessment');++ if (decision === 'accept' && e.strength === 'inferred' && this.s.policy.mode !== 'auto') { decision = 'ignore'; audit.would_accept = true; }++ let ticket;++ if (decision === 'accept') {++ assert(text(scope), 'Acceptance needs a scope');++ assert(!e.ids.some(id => this.owners(id).length), 'Worker owns follow-up');++ const t = this.s.triggers[e.primary];++ ticket = { id: this.id('ticket'), trigger_id: t.id, mandate: { policy_version: e.policy_version, scope, strength: e.strength, matched_rules: matched, actor: copy(t.actor), source_scope: t.source_scope, budget: copy(this.s.policy.budget) }, state: 'open', stage: 'working/queued', threads: [...t.threads] };++ this.validate('agent.ticket', ticket);++ this.s.tickets[ticket.id] = ticket;++ for (const id of e.ids) this.s.accepted[id] = ticket.id;++ this.s.meta[ticket.id] = { basis: e.basis, owned: [], handbacks: [], read: [] };++ for (const key of t.threads) this.thread(key).subscriptions[ticket.id] = 'origin';++ this.enqueue('launch', `launch:${ticket.id}`, { ticket, triggers: e.ids.map(id => this.s.triggers[id]) }, `worker:${ticket.id}`);++ }++ th.expiries = 0;++ return { decision: this.finish(th, decision, audit), ...(ticket ? { ticket } : {}) };++ }++ subscribe({ ticket_id, thread, kind = 'context' }) {++ const k = own(this.s.tickets, ticket_id);++ assert(k && k.state === 'open', 'Ticket must be open');++ assert(['context', 'attached', 'source'].includes(kind), 'Invalid subscription');++ // Attaching is an explicit router command; callers must supply the pointing trigger through attach().++ assert(kind !== 'attached', 'Use attach with an explicit ticket reference');++ this.thread(thread).subscriptions[ticket_id] ||= kind;++ if (!k.threads.includes(thread)) k.threads.push(thread);++ return k;++ }++ attach({ ticket_id, trigger_id }) {++ const k = own(this.s.tickets, ticket_id), t = own(this.s.triggers, trigger_id), m = own(this.s.meta, ticket_id);++ assert(k && t && t.actor.kind === 'person' && t.text.includes(ticket_id), 'Message must explicitly point at the Ticket');++ assert(!m.stopped && !m.revoked && !m.handbacks.some(h => h.trigger_id === trigger_id) && !this.owners(trigger_id).length, 'Message cannot be attached');++ assert(k.state === 'open' || revalidate(k, m, this.s.triggers[k.trigger_id], this.s.policy, this.s.sources), 'Mandate no longer holds');++ for (const key of t.threads) { this.thread(key).subscriptions[ticket_id] = 'attached'; if (!k.threads.includes(key)) k.threads.push(key); }++ if (k.state === 'ended') { k.state = 'open'; this.enqueue('launch', `resume:${ticket_id}:${trigger_id}`, { ticket: k }, `worker:${ticket_id}`); }++ m.owned.push(trigger_id);++ for (const th of Object.values(this.s.threads)) {++ th.pending = th.pending.filter(id => id !== trigger_id);++ if (th.evaluation?.ids.includes(trigger_id)) {++ const rest = th.evaluation.ids.filter(id => id !== trigger_id);++ this.finish(th, 'ignore', { reason: 'attached' });++ rest.forEach(id => this.queue(this.s.triggers[id]));++ }++ }++ this.enqueue('input', `attach:${ticket_id}:${trigger_id}`, { ticket_id, trigger: t }, `worker:${ticket_id}`);++ return k;++ }++ unsubscribe({ ticket_id, thread, person = false }) {++ const th = this.thread(thread), kind = th.subscriptions[ticket_id];++ if (person && !ticket_id) { th.subscriptions = {}; return true; }++ assert(person || !['origin', 'source'].includes(kind), 'Worker cannot drop origin or source subscription');++ delete th.subscriptions[ticket_id];++ // Ownership survives removal. Thread membership remains historical for audit.++ return true;++ }++ handBack({ ticket_id, trigger_id, reason }) {++ const m = own(this.s.meta, ticket_id);++ assert(m && text(reason), 'Hand-back needs a Ticket and reason');++ if (m.handbacks.some(h => h.trigger_id === trigger_id) || !m.owned.includes(trigger_id)) return false;++ m.handbacks.push({ ticket_id, trigger_id, reason, at: this.now });++ m.owned = m.owned.filter(id => id !== trigger_id);++ this.queue(own(this.s.triggers, trigger_id));++ return true;++ }++ read({ ticket_id, trigger_ids }) {++ const m = own(this.s.meta, ticket_id); assert(m, 'Unknown Ticket');++ assert(Array.isArray(trigger_ids), 'Read needs trigger ids');++ // Worker calls this after a report/turn end, not merely after transport delivery.++ for (const id of trigger_ids) { assert(m.owned.includes(id), 'Worker did not own input'); if (!m.read.includes(id)) m.read.push(id); }++ return true;++ }++ end({ ticket_id, reason = 'completed', person = false }) {++ const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);++ assert(k, 'Unknown Ticket'); k.state = 'ended'; m.ended_reason = reason;++ if (person) m.stopped = true;++ const unread = m.owned.filter(id => !m.read.includes(id)); m.owned = [];++ for (const id of unread) this.queue(this.s.triggers[id]);++ for (const w of this.s.work) if (w.conversation === `worker:${ticket_id}` && !['done', 'dropped'].includes(w.state)) { w.state = 'dropped'; w.reason = 'ticket_ended'; w.finished_at = this.now; }++ this.enqueue('end', `end:${ticket_id}:${this.s.seq}`, { ticket_id, reason }, `worker:${ticket_id}`);++ return k;++ }++ stop({ ticket_id, trigger_id, reason }) {++ const t = own(this.s.triggers, trigger_id), k = own(this.s.tickets, ticket_id);++ assert(t && k && t.threads.some(key => k.threads.includes(key)), 'Stop needs a message on the Ticket');++ const g = gate(t, this.s.policy, this.s.sources[t.source], true);++ assert(t.actor.kind === 'person' && g?.strength === 'explicit', 'Only explicit human stop withdraws a Mandate');++ const m = own(this.s.meta, ticket_id); m.read.push(trigger_id);++ this.end({ ticket_id, reason, person: true });++ for (const th of Object.values(this.s.threads)) th.pending = th.pending.filter(id => id !== trigger_id);++ const d = { id: this.id('decision'), trigger_ids: [trigger_id], policy_version: this.s.policy.version, decision: 'stop', strength: 'explicit', direction: emptyDirection(), rule_assessments: [], reason: reason || 'person_stop' };++ this.validate('agent.decision', d); this.s.decisions.push(d); return k;++ }++ delivered({ evaluation }) {++ const e = Object.values(this.s.threads).find(t => t.evaluation?.id === evaluation)?.evaluation;++ if (e && !e.deadline) e.deadline = this.now + 300000;++ }++ expire() {++ for (const th of Object.values(this.s.threads)) if (th.evaluation?.deadline <= this.now) {++ const ids = th.evaluation.ids;++ th.expiries = (th.expiries || 0) + 1;++ if (th.expiries <= 2) {++ const evalId = th.evaluation.id; th.evaluation = null;++ for (const w of this.s.work) if (w.key === `candidate:${evalId}` && w.state !== 'done') { w.state = 'dropped'; w.reason = 'assessment_timeout_retry'; w.finished_at = this.now; }++ ids.forEach(id => this.queue(this.s.triggers[id]));++ } else this.finish(th, 'unavailable', { reason: 'assessment_timeout' });++ }++ }++ reply({ ticket_id, conversation, key, text: body, source }) {++ assert(own(this.s.tickets, ticket_id) && this.s.sources[source] && text(body) && text(conversation) && text(key), 'Invalid reply');++ return this.enqueue('reply', `reply:${ticket_id}:${key}`, { ticket_id, text: body, source, source_scope: conversation }, `reply:${ticket_id}:${conversation}`);++ }++ claim({ kinds, lease_ms = 30000 }) {++ assert(Array.isArray(kinds) && Number.isFinite(lease_ms) && lease_ms >= 1000 && lease_ms <= 300000, 'Invalid work claim');++ const pending = this.s.work.filter(w => !['done', 'dropped'].includes(w.state));++ for (const w of pending) {++ if (!kinds.includes(w.kind) || w.due > this.now || (w.state === 'running' && w.lease_until > this.now)) continue;++ if (pending.some(earlier => earlier.conversation === w.conversation && this.s.work.indexOf(earlier) < this.s.work.indexOf(w))) continue;++ if (w.kind === 'reply') {++ const source = this.s.sources[w.payload.source];++ if (!source || !source.scopes.includes(w.payload.source_scope)) { w.state = 'dropped'; w.reason = 'source_no_longer_listens'; w.finished_at = this.now; continue; }++ if (!source.enabled) continue;++ }++ w.state = 'running'; w.claim = this.id('claim'); w.lease_until = this.now + lease_ms;++ return copy(w);++ }++ return null;++ }++ settle({ id, claim, ok, permanent_reason }) {++ const w = this.s.work.find(x => x.id === id);++ assert(w && w.claim === claim, 'Stale work claim');++ if (w.state === 'dropped') return w;++ assert(w.state === 'running', 'Stale work claim');++ if (ok) {++ w.state = 'done'; w.finished_at = this.now; delete w.last_error;++ if (w.kind === 'input') {++ const k = this.s.tickets[w.payload.ticket_id];++ if (k?.state === 'open' && k.stage === 'person/delivery') k.stage = 'working';++ }++ if (w.kind === 'launch') {++ const k = this.s.tickets[w.payload.ticket.id];++ if (k?.state === 'open') k.stage = 'working';++ }++ if (['route', 'assess'].includes(w.kind)) this.delivered({ evaluation: w.payload.evaluation.id });++ } else if (permanent_reason) {++ assert(w.kind === 'reply' && ['source_no_longer_listens', 'destination_cannot_receive'].includes(permanent_reason), 'Invalid permanent drop');++ w.state = 'dropped'; w.reason = permanent_reason; w.finished_at = this.now;++ } else {++ w.attempts++; w.state = 'pending'; w.last_error = 'delivery_failed';++ if (w.kind === 'input') {++ const k = this.s.tickets[w.payload.ticket_id];++ if (k?.state === 'open') k.stage = 'person/delivery';++ }++ w.due = this.now + Math.min(w.kind === 'reply' ? 300000 : 60000, (w.kind === 'reply' ? 5000 : 2000) * 2 ** Math.min(w.attempts - 1, 20));++ }++ return w;++ }++}++module.exports = { Engine, initial };nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++const { createHash } = require('node:crypto');++const assert = (ok, message) => { if (!ok) throw Object.assign(new Error(message), { status: 422 }); };++const text = (s) => typeof s === 'string' && s.trim().length > 0;++function policy(input = {}) {++ const p = structuredClone({ mode: 'observe', rules: [], sources: {}, budget: { usd: 0, tokens: 0, wall_minutes: 0 }, ...input });++ delete p.version;++ assert(['off', 'observe', 'auto'].includes(p.mode), 'Invalid accept mode');++ assert(Array.isArray(p.rules) && p.rules.every(r => text(r.id) && text(r.instruction) && typeof r.enabled === 'boolean') && new Set(p.rules.map(r => r.id)).size === p.rules.length, 'Rules need unique ids, instructions and enabled flags');++ assert(p.budget && ['usd', 'tokens', 'wall_minutes'].every(k => Number.isFinite(p.budget[k]) && p.budget[k] >= 0) && Number.isInteger(p.budget.tokens), 'Invalid mandate budget');++ assert(p.sources && typeof p.sources === 'object' && !Array.isArray(p.sources), 'Invalid source policies');++ for (const s of Object.values(p.sources)) {++ assert(Array.isArray(s.allowed) && Array.isArray(s.bots) && [...s.allowed, ...s.bots].every(text), 'Source identities must be lists');++ for (const row of Object.values(s.admission || {})) {++ assert(['inferred', 'ignore'].includes(row.listed_unaddressed || 'inferred'), 'Invalid listed admission');++ assert(['explicit', 'inferred', 'ignore'].includes(row.unlisted_addressed || 'ignore'), 'Invalid addressed admission');++ assert(['inferred', 'ignore'].includes(row.unlisted_unaddressed || 'ignore'), 'Invalid unaddressed admission');++ }++ }++ // Version covers every nested policy value, including budget; object insertion order is normalized recursively.++ const stable = v => Array.isArray(v) ? v.map(stable) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, stable(v[k])])) : v;++ p.version = createHash('sha256').update(JSON.stringify(stable(p))).digest('hex');++ return p;++}++function gate(t, p, source, followup = false) {++ const s = p.sources[t.source];++ if (!s || !source || source.enabled === false || !source.scopes.includes(t.source_scope)) return null;++ const addressed = ['mention', 'direct'].includes(t.addresses_agent);++ const all = (source.receive_all || []).includes(t.source_scope);++ const listed = s.allowed.includes(t.actor.id);++ const bot = t.actor.kind !== 'person' || s.bots.includes(t.actor.id);++ let strength, basis;++ if (bot) {++ if (!s.bots.includes(t.actor.id) || !(addressed || all)) return null;++ strength = 'inferred'; basis = 'bot';++ } else if (listed && addressed) { strength = 'explicit'; basis = 'addressed'; }++ else {++ const row = (s.admission || {})[t.source_scope] || {};++ const cell = listed ? 'listed_unaddressed' : addressed ? 'unlisted_addressed' : 'unlisted_unaddressed';++ const admission = (addressed || all) ? row[cell] || (listed ? 'inferred' : 'ignore') : 'ignore';++ if (admission === 'explicit') { strength = 'explicit'; basis = 'table'; }++ else if (followup) { strength = listed ? 'explicit' : 'inferred'; basis = 'followup'; }++ else if (admission === 'inferred') { strength = 'inferred'; basis = 'table'; }++ else return null;++ }++ return strength === 'inferred' && p.mode === 'off' ? null : { strength, basis };++}++function revalidate(ticket, meta, original, p, sources) {++ const m = ticket.mandate, source = sources[original.source];++ if (!source || source.enabled === false || !source.scopes.includes(original.source_scope)) return false;++ if (m.strength === 'inferred' && (p.mode !== 'auto' || m.matched_rules.some(id => !p.rules.some(r => r.id === id && r.enabled)))) return false;++ if (meta.basis === 'followup' && m.strength === 'explicit') return true;++ if (meta.basis === 'addressed' && !p.sources[original.source]?.allowed.includes(m.actor.id)) return false;++ const g = gate(original, p, source, meta.basis === 'followup');++ return !!g && (g.strength === 'explicit' || m.strength === 'inferred');++}++function assessment(a, p, triggers) {++ const rules = p.rules.filter(r => r.enabled), judged = a.rule_assessments;++ assert(Array.isArray(judged) && judged.length === rules.length && new Set(judged.map(r => r.rule_id)).size === rules.length && rules.every(r => judged.some(j => j.rule_id === r.id)), 'Exactly one assessment per enabled rule');++ assert(judged.every(r => typeof r.matched === 'boolean' && text(r.reason)), 'Invalid rule assessment');++ const d = a.direction;++ assert(d && typeof d.implementation_requested === 'boolean' && typeof d.requires_design_decision === 'boolean' && ['specified', 'existing-standard', 'undetermined'].includes(d.target_basis) && Array.isArray(d.evidence), 'Invalid structured direction');++ assert(d.evidence.every(e => text(e.quote) && triggers.some(t => t.id === e.trigger_id && t.text.includes(e.quote))), 'Evidence must quote a trigger verbatim');++ assert(['accept', 'ignore', 'unavailable'].includes(a.decision), 'Invalid assessment decision');++ const matched = judged.filter(r => r.matched).map(r => r.rule_id);++ const accepted = a.decision === 'accept' && d.implementation_requested && d.target_basis !== 'undetermined' && !d.requires_design_decision && d.evidence.length > 0 && matched.length > 0;++ if (accepted) assert(text(a.scope), 'Acceptance needs a scope');++ return { matched, accepted };++}++module.exports = { policy, gate, revalidate, assessment, assert, text };nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++const contracts = require('openvibe-contracts');++const { Engine, initial } = require('./engine');++const sources = require('./sources');++const { assert } = require('./policy');++const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered']);++function createFabric({ s, enabled = false, readMail, validate = (name, value) => {++ try { return contracts.assertValid(`${name}@1`, value); }++ catch (err) { if (err.code === 'contract.invalid') err.status = 422; throw err; }++} }) {++ const enabledCheck = () => { if (!enabled) throw Object.assign(new Error('Work fabric is disabled'), { status: 404 }); };++ async function transact(owner, agent, command) {++ enabledCheck();++ return s.db.tx(async db => {++ // Authorization is inside the transaction and is repeated for every command, including leases.++ const exists = await db.maybe('SELECT id FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);++ if (!exists) throw Object.assign(new Error('Agent not found'), { status: 404 });++ await db.query('INSERT INTO actor_work_fabric (agent_id, owner, state) VALUES ($1,$2,$3::jsonb) ON CONFLICT DO NOTHING', [agent, owner, JSON.stringify(initial())]);++ const row = await db.one('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);++ const engine = new Engine(row.state, { now: s.now(), validate });++ const result = command(engine);++ assert(!result || typeof result.then !== 'function', 'Engine commands must be synchronous');++ await db.query('UPDATE actor_work_fabric SET state = $3::jsonb, revision = revision + 1, updated_at = $4 WHERE agent_id = $1 AND owner = $2', [agent, owner, JSON.stringify(engine.s), s.iso()]);++ return structuredClone(result ?? null);++ });++ }++ return {++ enabled,++ async snapshot(owner, agent) {++ enabledCheck();++ const row = await s.db.maybe('SELECT f.state, f.revision FROM actor_work_fabric f JOIN actor_agents a ON a.id = f.agent_id WHERE f.agent_id = $1 AND f.owner = $2 AND a.owner = $2', [agent, owner]);++ if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });++ return row;++ },++ command(owner, agent, name, args = {}) {++ assert(COMMANDS.has(name) || ['schedule', 'cancelSchedule', 'tick'].includes(name), 'Unknown fabric command');++ return transact(owner, agent, engine => {++ if (name === 'schedule') return sources.schedule(engine, args);++ if (name === 'cancelSchedule') { assert(Object.hasOwn(engine.s.schedules, args.id), 'Unknown schedule'); engine.s.schedules[args.id].cancelled = true; return true; }++ if (name === 'tick') return sources.tick(engine);++ if (name === 'configure' && args.sources) {++ args = structuredClone(args);++ for (const [id, source] of Object.entries(args.sources)) {++ source.owner_subject = owner.replace(/^user:/, '');++ delete source.cursor; delete source.message_threads;++ const old = engine.s.sources[id];++ if (old?.kind === source.kind) { source.cursor = old.cursor; source.message_threads = old.message_threads; }++ }++ }++ return engine[name](args);++ });++ },++ worker(owner, agent, ticket) {++ const allowed = new Set(['handBack', 'read', 'end', 'subscribe', 'unsubscribe', 'reply']);++ return {++ command: (name, args = {}) => {++ assert(allowed.has(name), 'Command is not available to a worker');++ // Worker ports cannot claim a human stop, change policy or create an authoritative binding.++ const safe = { ...args, ticket_id: ticket, person: false };++ if (name === 'subscribe') safe.kind = 'context';++ return this.command(owner, agent, name, safe);++ },++ };++ },++ async onEvent(event) {++ if (!enabled || !sources.TOPICS.includes(event.event_type)) return [];++ const rows = await s.db.many('SELECT agent_id, owner, state FROM actor_work_fabric ORDER BY agent_id');++ const out = [];++ for (const row of rows) {++ let letter;++ if (event.event_type === 'mail.agent_inbox.received') {++ const p = event.payload || {};++ const matches = Object.values(row.state.sources).some(x => x.kind === 'mail' && x.enabled && x.owner_subject === p.owner && x.mail_agent === p.agent_id && x.scopes.includes(`inbox:${p.inbox_id}`));++ if (!matches) continue;++ assert(typeof readMail === 'function', 'Mail Source needs its authorized read port');++ letter = await readMail({ person: p.owner, agentId: p.agent_id, messageId: p.message_id });++ contracts.assertValid('mail.agent-inbox-message@1', letter);++ if (letter.spam) { out.push({ status: 'filtered' }); continue; }++ }++ out.push(...await transact(row.owner, row.agent_id, e => sources.receiveEvent(e, event, letter)));++ }++ return out;++ },++ async tick() {++ if (!enabled) return;++ const rows = await s.db.many('SELECT agent_id, owner FROM actor_work_fabric ORDER BY agent_id');++ for (const row of rows) await transact(row.owner, row.agent_id, sources.tick);++ },++ // An injected adapter performs work only AFTER claim commits. Stable keys are required at the receiver:++ // transport is at-least-once, and a crash after send but before settle retries the same key.++ async drain(owner, agent, handlers) {++ const work = await this.command(owner, agent, 'claim', { kinds: Object.keys(handlers) });++ if (!work) return false;++ try {++ const ticket = work.payload.ticket?.id || work.payload.ticket_id;++ await handlers[work.kind](work.payload, { idempotency_key: work.key, ...(ticket ? { worker: this.worker(owner, agent, ticket) } : {}) });++ await this.command(owner, agent, 'settle', { id: work.id, claim: work.claim, ok: true });++ } catch {++ await this.command(owner, agent, 'settle', { id: work.id, claim: work.claim, ok: false });++ }++ return true;++ },++ };++}++module.exports = { createFabric };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++const { createHash } = require('node:crypto');++const { assert, text } = require('./policy');++const key = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');++const TOPICS = ['chat.message.created', 'chat.dm.created', 'mail.agent_inbox.received'];++// Plugins consume verified Events envelopes only. Source instance bindings are owner configuration, never++// inferred from arbitrary event text. The cursor and resulting Trigger commit in the same transaction.++const plugins = {++ chat(event, source) {++ const p = event.payload || {}, dm = event.event_type === 'chat.dm.created';++ if (!['chat.message.created', 'chat.dm.created'].includes(event.event_type) || p.source_platform || !p.message_id) return null;++ const scope = dm ? `dm:${p.conversation_id}` : p.room?.type === 'global' ? 'global' : `channel:${p.room?.channel_user_id}`;++ if (!source.scopes.includes(scope)) return null;++ const actor = dm ? p.sender_subject : p.user_subject;++ if (!text(actor)) return null;++ // An explicitly bound DM is a Source; it must include the agent's Chat identity as a participant.++ if (dm && (!(p.participants || []).some(x => x.subject === source.self) || !(p.participants || []).some(x => x.subject === source.owner_subject))) return null;++ const mentioned = typeof p.text === 'string' && (source.mentions || []).some(name => p.text.split(/\s+/).some(word => word.replace(/[,:!?]+$/, '') === `@${name}`));++ if (!dm && !mentioned && !(source.receive_all || []).includes(scope)) return null;++ return { event_id: `${event.event_type}:${p.message_id}`, scope, thread: dm ? scope : source.message_threads?.[`message_${p.reply_to_id}`] || `message_${p.reply_to_id || p.message_id}`, actor, kind: p.message_type === 'bot' || p.message_type === 'ai' ? 'bot' : 'person', address: dm ? 'direct' : mentioned ? 'mention' : 'none',++ // DM events intentionally carry identifiers, not private message bodies. Preserve those source facts;++ // an executor reads the message through Chat with its own authorized connection.++ text: dm ? JSON.stringify(p) : p.text, pointer: `https://openvibe.chat/${dm ? `dm/${p.conversation_id}` : `messages/${p.message_id}`}` };++ },++ mail(event, source, letter) {++ if (event.event_type !== 'mail.agent_inbox.received') return null;++ const p = event.payload || {}, scope = `inbox:${p.inbox_id}`;++ if (!source.scopes.includes(scope) || p.owner !== source.owner_subject || p.agent_id !== source.mail_agent || !text(p.message_id) || !text(p.from)) return null;++ if (!letter || !Array.isArray(letter.from) || letter.from.length !== 1 || letter.from[0].trim().toLowerCase() !== p.from.trim().toLowerCase() || letter.spam || letter.owner !== source.owner_subject || letter.agent_id !== source.mail_agent || letter.inbox !== p.inbox) return null;++ return { event_id: p.message_id, scope, thread: letter.references?.[0] || letter.in_reply_to || letter.message_id, actor: p.from.trim().toLowerCase(), kind: 'person', address: 'direct', text: letter.text || JSON.stringify({ message_id: p.message_id, subject: letter.subject || '' }), pointer: `https://openvibe.email/inbox/${encodeURIComponent(p.inbox_id)}/message/${encodeURIComponent(p.message_id)}` };++ },++ time(event, source) {++ if (event.event_type !== 'actor.fabric.time' || !source.scopes.includes(event.payload.scope)) return null;++ const p = event.payload;++ return { event_id: `${p.schedule_id}:${p.due}`, scope: p.scope, actor: 'clock', kind: 'system', address: 'none', text: p.text };++ },++};++function translate(event, id, source, now, letter) {++ const raw = plugins[source.kind]?.(event, source, letter);++ if (!raw || !text(raw.text) || raw.actor === source.self || !source.enabled) return null;++ return { id: `trigger_${key(id, raw.event_id)}`, source: id, actor: { source: id, id: raw.actor, kind: raw.kind }, source_scope: raw.scope,++ threads: [`thread_${key(id, raw.scope, raw.thread || raw.scope)}`], occurred_at: new Date(event.occurred_at || event.created_at || now).toISOString(), text: raw.text, addresses_agent: raw.address, ...(raw.pointer ? { read_pointer: raw.pointer } : {}) };++}++function receiveEvent(engine, event, letter) {++ const results = [];++ for (const [id, source] of Object.entries(engine.s.sources)) {++ if (source.kind === 'time') continue; // only the local time Source may produce clock events++ const t = translate(event, id, source, engine.now, letter);++ if (t) {++ results.push(engine.receive(t));++ source.cursor = event.event_id || event.id || t.id;++ if (source.kind === 'chat' && event.event_type === 'chat.message.created') {++ source.message_threads ||= {};++ const p = event.payload;++ source.message_threads[`message_${p.message_id}`] = source.message_threads[`message_${p.reply_to_id}`] || `message_${p.reply_to_id || p.message_id}`;++ }++ }++ }++ return results;++}++function schedule(engine, spec) {++ const source = engine.s.sources[spec.source];++ assert(source?.kind === 'time' && source.scopes.includes(spec.scope), 'Unknown time Source scope');++ assert(text(spec.id) && !['__proto__', 'constructor', 'prototype'].includes(spec.id) && text(spec.text) && Number.isFinite(spec.due) && spec.due >= engine.now && (spec.interval_ms == null || Number.isSafeInteger(spec.interval_ms) && spec.interval_ms >= 1000), 'Invalid schedule');++ assert(!Object.hasOwn(engine.s.schedules, spec.id), 'Schedules are immutable; cancel and create a new id');++ if (spec.quiet_thread) assert(engine.s.threads[spec.quiet_thread]?.last_trigger_id === spec.since_trigger, 'Quiet condition needs the latest Trigger on its Thread');++ engine.s.schedules[spec.id] = structuredClone(spec); return spec;++}++function tick(engine) {++ for (const s of Object.values(engine.s.schedules)) {++ const source = engine.s.sources[s.source];++ if (s.cancelled || s.finished || s.due > engine.now || !source?.enabled) continue;++ if (s.quiet_thread && engine.s.threads[s.quiet_thread]?.last_trigger_id !== s.since_trigger) { s.cancelled = true; s.reason = 'conversation_changed'; continue; }++ const event = { event_type: 'actor.fabric.time', occurred_at: new Date(s.due).toISOString(), payload: { schedule_id: s.id, due: s.due, scope: s.scope, text: s.text } };++ const trigger = translate(event, s.source, source, engine.now);++ if (trigger) engine.receive(trigger);++ source.cursor = trigger?.id;++ // One occurrence per pass. Catch-up is ordered and bounded; no waiting inside Eval.++ if (s.interval_ms) s.due += s.interval_ms; else s.finished = true;++ }++ engine.expire();++ for (const thread of Object.keys(engine.s.threads)) engine.start({ thread });++}++module.exports = { plugins, translate, receiveEvent, schedule, tick, TOPICS };@@@ -101,6 +101,7 @@ const TABLES = [{ table: 'actor_agent_proposals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-suggestions.json' },// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },++ { table: 'actor_work_fabric', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric.json', erase: 'delete' },{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.// The document is their own text, and the link is theirs to stop sharing.@@@ -67,8 +67,11 @@ async function start() {// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and// ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '',-- topics: config.chat.asks ? ['chat.message.created'] : [] });++ topics: [...(config.chat.asks ? ['chat.message.created'] : []), ...(ctx.fabric.enabled ? require('./fabric/sources').TOPICS : [])] });const extra = [() => { if (subscriptions) subscriptions.stop(); }];++ if (ctx.fabric.enabled && !config.drill) {++ extra.push(require('./utils/jobs').every('actor-fabric-time', 1000, () => ctx.fabric.tick(), { initialDelayMs: 1000, log: console }));++ }const dayJobs = ctx.day.startJobs();extra.push(() => { for (const stop of dayJobs) stop(); });// The waits (plan A2): the deadline loop fires the waits whose time is up, and the sweep reads the topics@@@ -102,10 +102,9 @@ const SAME = { origin: 'https://openvibe.actor' };const edited = await t.get(`/tasks/${id}/assumptions`, { as: bea, form: { assumptions: 'Use EUR\nAsk before booking' }, headers: SAME });assert.strictEqual(edited.status, 303);t.providers.state.runtime = 'tool';-- // Up to 10 s: the follow-up turn runs after the hang is released, and a loaded CI runner took longer than-- // the 2.5 s this waited before (Actor #384 failed on it three times on 2026-11-09; it passes locally in 9 s).++ // Up to 30 s: the follow-up turn can wait behind the runtime queue on a loaded CI runner.let seen = false;-- for (const until = Date.now() + 10000; !seen && Date.now() < until;) {++ for (const until = Date.now() + 30000; !seen && Date.now() < until;) {const events = await store.eventsAfter(s, id, 0);seen = events.some((e) => e.step === 'plan' && /Assumptions: Use EUR; Ask before booking/.test(String(e.text)));if (!seen) await new Promise((r) => setTimeout(r, 50));nnew file mode 100644@@@ -0,0 +1,15 @@++'use strict';++const assert = require('node:assert/strict');++const contracts = require('openvibe-contracts');++const { Engine, initial } = require('../server/fabric/engine');++// This is intentionally a hard dependency: the fabric must not enable against a Contracts pin++// without the released agent.* schemas. Never replace this with local schemas or a branch pin.++for (const name of ['trigger', 'ticket', 'decision']) assert(contracts.schema(`agent.${name}@1`));++const e = new Engine(initial(), { now: 1, validate: (name, value) => contracts.assertValid(`${name}@1`, value) });++e.configure({ sources: { chat: { kind: 'chat', enabled: true, scopes: ['room'], self: 'agent' } }, policy: { sources: { chat: { allowed: ['person'], bots: [], admission: {} } } } });++e.receive({ id: 'original', source: 'chat', actor: { source: 'chat', id: 'person', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T00:00:00.000Z', text: 'Fix test', addresses_agent: 'mention' });++const ev = e.start({ thread: 'room' });++const out = e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Fix test' });++assert.equal(out.ticket.mandate.policy_version, e.s.policy.version);++assert.equal(out.ticket.stage, 'working/queued');++console.log('001 Trigger, Ticket/Mandate and 002 Decision conform to released agent.*@1 schemas');nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { createFabric } = require('../server/fabric/service');++const contacts = require('../server/agents/contacts');++const config = require('../server/config');++const A = 'usr_01JZ0000000000000000000AAA', B = 'usr_01JZ0000000000000000000BBB';++(async () => {++ const t = await boot({ env: { ACTOR_WORK_FABRIC: '1' } });++ try {++ const s = t.ctx.s;++ const alice = t.network.addUser('alice'); alice.subject = A;++ const bob = t.network.addUser('bob'); bob.subject = B;++ await contacts.ensureVibe(s, t.config, `user:${A}`);++ const agent = (await contacts.list(s, t.config, `user:${A}`))[0].id;++ // Storage tests exercise rollback separately from the released-contract tests.++ const f = createFabric({ s, enabled: true, validate: () => {} });++ const c = { sources: { chat: { kind: 'chat', enabled: true, self: 'bot', scopes: ['global'], mentions: ['actor'] } }, policy: { sources: { chat: { allowed: [A], bots: [], admission: {} } } } };++ await check('001 transaction: feature defaults off and owner is checked on every command/read', async () => {++ assert.equal(config.load({}).fabric.enabled, false);++ await assert.rejects(() => createFabric({ s }).command(`user:${A}`, agent, 'configure', c), /disabled/);++ await assert.rejects(() => f.command(`user:${B}`, agent, 'configure', c), /not found/);++ await f.command(`user:${A}`, agent, 'configure', c);++ await assert.rejects(() => f.snapshot(`user:${B}`, agent), /not found/);++ assert.equal((await f.snapshot(`user:${A}`, agent)).state.sources.chat.owner_subject, A);++ });++ const ev = { event_id: 'ev1', event_type: 'chat.message.created', payload: { message_id: 1, user_subject: A, text: '@actor fix a test', room: { type: 'global' } } };++ await check('001 atomic intake: concurrent replays store once with one cursor and one Eval', async () => {++ await Promise.all([f.onEvent(ev), f.onEvent(ev)]);++ let snap = await f.snapshot(`user:${A}`, agent); assert.equal(Object.keys(snap.state.triggers).length, 1); assert.equal(snap.state.sources.chat.cursor, 'ev1');++ const thread = Object.keys(snap.state.threads)[0];++ const evaluations = await Promise.all([f.command(`user:${A}`, agent, 'start', { thread }), f.command(`user:${A}`, agent, 'start', { thread })]);++ assert.equal(evaluations[0].id, evaluations[1].id);++ const strict = createFabric({ s, enabled: true, validate: name => { if (name === 'agent.decision') throw new Error('validation rejected'); } });++ await assert.rejects(() => strict.command(`user:${A}`, agent, 'decide', { evaluation: evaluations[0].id, decision: 'accept', scope: 'Fix test' }), /validation rejected/);++ snap = await f.snapshot(`user:${A}`, agent); assert.equal(Object.keys(snap.state.tickets).length, 0); assert(!snap.state.work.some(w => w.kind === 'launch')); assert(snap.state.threads[thread].evaluation);++ await f.command(`user:${A}`, agent, 'decide', { evaluation: evaluations[0].id, decision: 'accept', scope: 'Fix test' });++ });++ await check('003 Stored work: handler observes committed Ticket; failed handler retains work after restart', async () => {++ let called = 0;++ await f.drain(`user:${A}`, agent, { launch: async (_payload, meta) => {++ const saved = await f.snapshot(`user:${A}`, agent); assert.equal(Object.keys(saved.state.tickets).length, 1); assert(meta.idempotency_key); called++; throw new Error('offline');++ } });++ assert.equal(called, 1);++ const recovered = createFabric({ s, enabled: true });++ const snap = await recovered.snapshot(`user:${A}`, agent), work = snap.state.work.find(w => w.kind === 'launch');++ assert.equal(work.state, 'pending'); assert.equal(work.attempts, 1);++ });++ await check('002 Executor port: worker cannot withdraw authority, change policy or widen a subscription', async () => {++ const before = await f.snapshot(`user:${A}`, agent), ticket = Object.keys(before.state.tickets)[0];++ const worker = f.worker(`user:${A}`, agent, ticket);++ assert.throws(() => worker.command('configure', {}), /not available/);++ await worker.command('subscribe', { thread: 'related', kind: 'source' });++ await worker.command('end', { person: true });++ const after = await f.snapshot(`user:${A}`, agent);++ assert.equal(after.state.threads.related.subscriptions[ticket], 'context');++ assert.equal(after.state.meta[ticket].stopped, undefined);++ });++ await check('API: unauthenticated and foreign owner cannot read or mutate fabric', async () => {++ const anon = await t.get(`/api/v1/fabric/${agent}`); assert([401, 403].includes(anon.status));++ const foreign = await t.get(`/api/v1/fabric/${agent}`, { as: bob }); assert.equal(foreign.status, 404);++ const own = await t.get(`/api/v1/fabric/${agent}`, { as: alice }); assert.equal(own.status, 200);++ const invalid = await t.get(`/api/v1/fabric/${agent}/commands/receive`, { as: alice, headers: { Origin: 'https://openvibe.actor' }, json: {} }); assert.equal(invalid.status, 422);++ });++ } finally { await t.close(); }++ done();++})().catch(err => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,236 @@++'use strict';++const assert = require('node:assert/strict');++const { Engine, initial } = require('../server/fabric/engine');++const { gate, policy } = require('../server/fabric/policy');++const { translate, receiveEvent, schedule, tick } = require('../server/fabric/sources');++const cfg = () => ({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }], sources: { chat: { allowed: ['alice'], bots: ['ci'], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } }, budget: { usd: 1, tokens: 1000, wall_minutes: 10 } } });++const trigger = (id, extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', ...extra });++const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested defect fix' }] });++const engine = () => { const e = new Engine(initial(), { now: 1000 }); e.configure(cfg()); return e; };++const accept = (e, t = trigger('a')) => { e.receive(t); const ev = e.start({ thread: t.threads[0] }); return e.decide({ evaluation: ev.id, decision: 'accept', scope: t.text }).ticket; };++let count = 0;++function test(name, fn) { fn(); count++; console.log(`ok ${name}`); }++test('001 Trigger: immutable idempotent intake and one acceptance for the whole batch', () => {++ const e = engine(); e.receive(trigger('a')); e.receive(trigger('b')); e.receive(trigger('a', { text: 'changed' }));++ assert.equal(e.s.triggers.a.text, 'Fix the broken test');++ const ev = e.start({ thread: 'room' }); assert.equal(e.start({ thread: 'room' }).id, ev.id);++ e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Fix test' });++ e.queue(trigger('b')); assert.equal(e.start({ thread: 'room' }), null); assert.equal(Object.keys(e.s.tickets).length, 1);++});++test('001 Thread: new arrivals wait for the next evaluation and see ended Tickets', () => {++ const e = engine(); e.receive(trigger('a')); const first = e.start({ thread: 'room' }); e.receive(trigger('b'));++ assert.deepEqual(e.s.threads.room.pending, ['b']);++ e.decide({ evaluation: first.id, decision: 'ignore' }); assert.deepEqual(e.start({ thread: 'room' }).ids, ['b']);++});++test('001 Source: own messages and unlistened scopes are filtered before domain intake', () => {++ const e = engine(); assert.equal(e.receive(trigger('a', { actor: { source: 'chat', id: 'agent', kind: 'bot' } })).status, 'filtered');++ assert.equal(e.receive(trigger('b', { source_scope: 'elsewhere' })).status, 'filtered'); assert.deepEqual(e.s.triggers, {});++});++test('002 Gate: allowed addressed people explicit in every mode; no assessment work', () => {++ for (const mode of ['off', 'observe', 'auto']) {++ const e = engine(), c = cfg(); c.policy.mode = mode; e.configure(c);++ const k = accept(e); assert.equal(k.mandate.strength, 'explicit'); assert(!e.s.work.some(w => w.kind === 'assess'));++ }++});++test('002 Admission: every cell, follow-up precedence, receive-all and bots', () => {++ const e = engine(), p = e.s.policy, s = e.s.sources.chat;++ assert.equal(gate(trigger('a', { addresses_agent: 'none' }), p, s).strength, 'inferred');++ const other = trigger('a', { actor: { source: 'chat', id: 'bob', kind: 'person' } });++ assert.equal(gate(other, p, s, true).strength, 'inferred');++ p.sources.chat.admission.room.unlisted_addressed = 'explicit'; p.mode = 'off';++ assert.equal(gate(other, p, s, true).strength, 'explicit');++ assert.equal(gate({ ...other, addresses_agent: 'none' }, p, s, true), null);++ p.mode = 'auto';++ assert.equal(gate(trigger('x', { actor: { source: 'chat', id: 'ci', kind: 'bot' } }), p, s).strength, 'inferred');++ assert.equal(gate(trigger('x', { actor: { source: 'chat', id: 'unknown', kind: 'bot' } }), p, s), null);++ assert.equal(gate({ ...other, addresses_agent: 'none' }, p, { ...s, receive_all: [] }), null);++});++test('002 Assessment: inferred acceptance requires every enabled rule exactly once and quoted direction', () => {++ for (const mutation of [a => a.rule_assessments.pop(), a => a.rule_assessments.push(a.rule_assessments[0]), a => a.direction.evidence[0].quote = 'invented']) {++ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' }), a = good(); mutation(a);++ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', assessment: a })); assert.equal(Object.keys(e.s.tickets).length, 0);++ }++ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' });++ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', scope: 'bypass' }));++ const k = e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }).ticket;++ assert.deepEqual(k.mandate.matched_rules, ['fix']); assert.deepEqual(k.mandate.budget, cfg().policy.budget);++});++test('002 Assessment: unsettled direction, design decisions and unmatched rules never accept', () => {++ for (const mutate of [a => a.direction.requires_design_decision = true, a => a.direction.target_basis = 'undetermined', a => a.direction.implementation_requested = false, a => a.rule_assessments[0].matched = false]) {++ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' }), a = good(); mutate(a);++ assert.equal(e.decide({ evaluation: ev.id, decision: 'accept', assessment: a }).decision.decision, 'ignore');++ }++});++test('002 Observe default: would-accept is audit only, with no worker or reply', () => {++ assert.equal(initial().policy.mode, 'observe');++ const e = engine(), c = cfg(); c.policy.mode = 'observe'; e.configure(c);++ e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' });++ const d = e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }).decision;++ assert.equal(d.decision, 'ignore'); assert.equal(d.would_accept, true); assert(!e.s.work.some(w => ['launch', 'reply'].includes(w.kind)));++});++test('002 Routing: ignored-from-card skips assessment and cannot accept', () => {++ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' });++ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', ignored_from_card: true, reason: 'unrelated' }));++ assert.equal(e.decide({ evaluation: ev.id, decision: 'ignore', ignored_from_card: true, reason: 'unrelated' }).decision.ignored_from_card, true);++});++test('001 Policy / 002 Policy version: discard stale Eval and notify affected live Tickets without revoking', () => {++ const e = engine(), k = accept(e), mandate = structuredClone(k.mandate);++ e.receive(trigger('b', { actor: { source: 'chat', id: 'ci', kind: 'bot' } })); const ev = e.start({ thread: 'room' });++ const c = cfg(); c.policy.sources.chat.allowed = []; e.configure(c);++ assert.equal(k.state, 'open'); assert.deepEqual(k.mandate, mandate); assert(e.s.work.some(w => w.key.startsWith('policy:')));++ assert.equal(e.s.decisions.at(-1).decision, 'unavailable'); assert.deepEqual(e.s.threads.room.pending, ['b']);++ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }));++});++test('001 Subscription / 002 Ownership: every sender fans out, human follow-ups bypass Eval', () => {++ const e = engine(), k = accept(e);++ e.receive(trigger('b', { actor: { source: 'chat', id: 'stranger', kind: 'person' } }));++ assert.deepEqual(e.owners('b'), [k.id]); assert.equal(e.start({ thread: 'room' }), null);++ e.unsubscribe({ ticket_id: k.id, thread: 'room', person: true }); assert.deepEqual(e.owners('b'), [k.id]);++ e.subscribe({ ticket_id: k.id, thread: 'room', kind: 'source' });++ e.receive(trigger('c', { actor: { source: 'chat', id: 'unknown', kind: 'bot' } }));++ assert(e.s.work.some(w => w.payload.trigger?.id === 'c')); assert.deepEqual(e.owners('c'), []);++});++test('001 Correlation / 002 Context-only: related Threads never route or grant resume authority', () => {++ const e = engine(), k = accept(e);++ e.subscribe({ ticket_id: k.id, thread: 'context' }); e.end({ ticket_id: k.id });++ e.receive(trigger('b', { threads: ['context'] })); assert.equal(k.state, 'ended'); assert.deepEqual(e.owners('b'), []);++ e.s.threads.context.correlated = ['room']; e.receive(trigger('c', { threads: ['context'] })); assert.equal(k.state, 'ended');++});++test('002 Hand-back: once with a reason; wait until every owner releases; keep subscriptions', () => {++ const e = engine(), one = accept(e);++ e.receive(trigger('bot', { actor: { source: 'chat', id: 'ci', kind: 'bot' } }));++ const ev = e.start({ thread: 'room' }), a = good(); a.direction.evidence[0].trigger_id = 'bot';++ const two = e.decide({ evaluation: ev.id, decision: 'accept', assessment: a }).ticket;++ e.receive(trigger('follow'));++ assert.throws(() => e.handBack({ ticket_id: one.id, trigger_id: 'follow', reason: '' }));++ assert(e.handBack({ ticket_id: one.id, trigger_id: 'follow', reason: 'Another scope' })); assert.equal(e.s.threads.room.pending.length, 0);++ assert.equal(e.handBack({ ticket_id: one.id, trigger_id: 'follow', reason: 'again' }), false);++ e.handBack({ ticket_id: two.id, trigger_id: 'follow', reason: 'Another scope' }); assert.deepEqual(e.s.threads.room.pending, ['follow']);++ assert(e.s.threads.room.subscriptions[one.id]);++});++test('002 Resume: person resumes same immutable Mandate, bots cannot; revoked policy fails closed', () => {++ const e = engine(), k = accept(e), mandate = structuredClone(k.mandate);++ e.end({ ticket_id: k.id });++ e.receive(trigger('bot', { actor: { source: 'chat', id: 'ci', kind: 'bot' } })); assert.equal(k.state, 'ended');++ e.receive(trigger('person')); assert.equal(k.state, 'open'); assert.deepEqual(k.mandate, mandate);++ e.read({ ticket_id: k.id, trigger_ids: ['person'] }); e.end({ ticket_id: k.id });++ const c = cfg(); c.policy.sources.chat.allowed = []; c.policy.sources.chat.admission.room.unlisted_addressed = 'ignore'; e.configure(c);++ e.receive(trigger('again')); assert.equal(k.state, 'ended'); assert.deepEqual(e.owners('again'), []); assert(e.s.threads.room.pending.includes('again'));++});++test('002 Stop: only explicit human stop withdraws; stopped Ticket receives nothing', () => {++ const e = engine(), k = accept(e);++ e.receive(trigger('bot', { actor: { source: 'chat', id: 'ci', kind: 'bot' } })); assert.throws(() => e.stop({ ticket_id: k.id, trigger_id: 'bot' }));++ e.receive(trigger('stop')); e.stop({ ticket_id: k.id, trigger_id: 'stop', reason: 'Person asked' });++ e.receive(trigger('later')); assert.equal(k.state, 'ended'); assert(!e.s.work.some(w => w.payload.trigger?.id === 'later'));++});++test('002 Release unread: read input stays accounted for; unread returns to routing on end', () => {++ const e = engine(), k = accept(e); e.receive(trigger('read')); e.receive(trigger('unread'));++ e.read({ ticket_id: k.id, trigger_ids: ['read'] }); e.end({ ticket_id: k.id }); assert.deepEqual(e.s.threads.room.pending, ['unread']);++});++test('002 Eval timeout: undelivered never expires; third delivered expiry is unavailable', () => {++ const e = engine(); e.receive(trigger('a')); let ev = e.start({ thread: 'room' }); e.now = 9999999; e.expire(); assert(e.s.threads.room.evaluation);++ for (let i = 0; i < 3; i++) { e.delivered({ evaluation: ev.id }); e.now += 300001; e.expire(); if (i < 2) ev = e.start({ thread: 'room' }); }++ assert.equal(e.s.threads.room.evaluation, null); assert.equal(e.s.threads.room.pending.length, 0); assert.equal(e.s.decisions.at(-1).decision, 'unavailable');++});++test('003 Stored work: order, exponential retries, leases and restart use the same idempotency key', () => {++ const e = engine(), k = accept(e); e.reply({ ticket_id: k.id, conversation: 'room', source: 'chat', key: 'ack', text: 'Acknowledged' }); e.reply({ ticket_id: k.id, conversation: 'room', source: 'chat', key: 'answer', text: 'Result' });++ let w = e.claim({ kinds: ['reply'] }); e.settle({ id: w.id, claim: w.claim, ok: false });++ assert.equal(e.s.work.find(x => x.id === w.id).due, 6000); assert.equal(e.claim({ kinds: ['reply'] }), null);++ e.now = 6000; w = e.claim({ kinds: ['reply'] });++ const restarted = new Engine(structuredClone(e.s), { now: 37000 }); const retry = restarted.claim({ kinds: ['reply'] }); assert.equal(retry.key, w.key); assert.notEqual(retry.claim, w.claim);++ assert.throws(() => restarted.settle({ id: w.id, claim: w.claim, ok: true })); restarted.settle({ id: retry.id, claim: retry.claim, ok: true });++ assert.equal(restarted.claim({ kinds: ['reply'] }).payload.text, 'Result');++});++test('003 Channel: Source off waits; permanent drop records when and why and never requeues', () => {++ const e = engine(), k = accept(e), args = { ticket_id: k.id, conversation: 'room', source: 'chat', key: 'ack', text: 'Ack' };++ e.reply(args); e.s.sources.chat.enabled = false; assert.equal(e.claim({ kinds: ['reply'] }), null); e.s.sources.chat.enabled = true;++ const w = e.claim({ kinds: ['reply'] }); e.settle({ id: w.id, claim: w.claim, permanent_reason: 'destination_cannot_receive' });++ assert.equal(e.reply(args).state, 'dropped'); assert.equal(e.reply(args).finished_at, e.now);++});++test('001 Source plugins: Chat mention/DM and Mail bind scope and owner; cursor shares intake', () => {++ const e = engine(); e.s.sources.chat.scopes = ['global', 'dm:2']; e.s.sources.chat.mentions = ['actor']; e.s.sources.chat.owner_subject = 'alice';++ const event = { id: 'ev1', event_type: 'chat.message.created', payload: { message_id: 1, room: { type: 'global' }, user_subject: 'alice', text: '@actor fix test' } };++ assert.equal(receiveEvent(e, event)[0].status, 'stored'); assert.equal(e.s.sources.chat.cursor, 'ev1'); assert.equal(receiveEvent(e, event)[0].status, 'duplicate');++ const dm = { event_type: 'chat.dm.created', payload: { message_id: 2, conversation_id: 2, sender_subject: 'alice', participants: [{ subject: 'alice' }, { subject: 'agent' }] } };++ assert.equal(translate(dm, 'chat', e.s.sources.chat, e.now).addresses_agent, 'direct'); dm.payload.participants = [{ subject: 'agent' }]; assert.equal(translate(dm, 'chat', e.s.sources.chat, e.now), null);++ const mail = { kind: 'mail', enabled: true, self: '[email protected]', owner_subject: 'alice', mail_agent: 'agent', scopes: ['inbox:1'] };++ const letter = { event_type: 'mail.agent_inbox.received', payload: { inbox_id: 1, owner: 'alice', agent_id: 'agent', from: '[email protected]', message_id: 'm1', inbox: '[email protected]', excerpt: 'hello' } };++ const full = { from: ['[email protected]'], owner: 'alice', agent_id: 'agent', inbox: '[email protected]', text: 'hello', message_id: 'm1', references: [] };++ const first = translate(letter, 'mail', mail, e.now, full); assert.equal(first.actor.id, '[email protected]');++ const next = translate({ ...letter, payload: { ...letter.payload, message_id: 'm2' } }, 'mail', mail, e.now, { ...full, message_id: 'm2', references: ['m1'] });++ assert.deepEqual(first.threads, next.threads);++ const unrelated = translate({ ...letter, payload: { ...letter.payload, message_id: 'm3' } }, 'mail', mail, e.now, { ...full, message_id: 'm3' });++ assert.notDeepEqual(first.threads, unrelated.threads);++ letter.payload.owner = 'bob'; assert.equal(translate(letter, 'mail', mail, e.now, full), null);++});++test('001 / 002 Waiting conditions: time Source produces idempotent events; evaluator never waits', () => {++ const e = engine(); e.s.sources.clock = { kind: 'time', enabled: true, self: 'agent', scopes: ['morning'], receive_all: ['morning'] };++ schedule(e, { id: 'daily', source: 'clock', scope: 'morning', due: 2000, text: 'Morning check' }); tick(e); assert.equal(Object.keys(e.s.triggers).length, 0);++ e.now = 2000; tick(e); tick(e); assert.equal(Object.keys(e.s.triggers).length, 1); assert.equal(e.s.triggers[Object.keys(e.s.triggers)[0]].actor.kind, 'system');++});++test('002 Revalidation: table weakening, rule disablement, bot removal and Source removal revoke on resume', () => {++ for (const change of [c => c.policy.mode = 'observe', c => c.policy.rules[0].enabled = false, c => c.policy.sources.chat.bots = [], c => c.sources.chat.enabled = false]) {++ const e = engine(); e.receive(trigger('a', { actor: { source: 'chat', id: 'ci', kind: 'bot' } }));++ const ev = e.start({ thread: 'room' }), k = e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }).ticket;++ e.end({ ticket_id: k.id }); const c = cfg(); change(c); e.configure(c); e.receive(trigger('later')); assert.equal(k.state, 'ended');++ }++ const e = engine(), c = cfg(); c.policy.sources.chat.admission.room.unlisted_addressed = 'explicit'; e.configure(c);++ const k = accept(e, trigger('a', { actor: { source: 'chat', id: 'bob', kind: 'person' } })); e.end({ ticket_id: k.id });++ c.policy.sources.chat.admission.room.unlisted_addressed = 'inferred'; e.configure(c); e.receive(trigger('later')); assert.equal(k.state, 'ended');++});++test('002 Attach: explicit pointer only, context upgraded, handed-back work cannot return', () => {++ const e = engine(), k = accept(e); e.subscribe({ ticket_id: k.id, thread: 'other' });++ e.receive(trigger('other1', { threads: ['other'], text: 'Different work' }));++ assert.throws(() => e.attach({ ticket_id: k.id, trigger_id: 'other1' }));++ e.receive(trigger('other2', { threads: ['other'], text: `Please continue ${k.id}` }));++ e.attach({ ticket_id: k.id, trigger_id: 'other2' }); assert.deepEqual(e.owners('other2'), [k.id]);++ e.handBack({ ticket_id: k.id, trigger_id: 'other2', reason: 'Beyond scope' }); assert.throws(() => e.attach({ ticket_id: k.id, trigger_id: 'other2' }));++ assert.throws(() => e.unsubscribe({ ticket_id: k.id, thread: 'room' }));++});++test('001 Subscription: many Threads deliver one input, multiple Tickets remain independent', () => {++ const e = engine(), k = accept(e); e.subscribe({ ticket_id: k.id, thread: 'other', kind: 'source' });++ e.receive(trigger('follow', { threads: ['room', 'other'] })); assert.equal(e.s.work.filter(w => w.payload.trigger?.id === 'follow').length, 1);++});++test('001 Policy: unchanged settings preserve version and unrelated changes do not notify', () => {++ const e = engine(), k = accept(e), old = e.s.policy.version; e.configure(cfg()); assert.equal(e.s.policy.version, old);++ const c = cfg(); c.policy.rules.push({ id: 'unrelated', enabled: true, instruction: 'Other work' }); e.configure(c);++ assert.notEqual(e.s.policy.version, old); assert(!e.s.work.some(w => w.key.startsWith('policy:'))); assert.equal(k.state, 'open');++});++test('001 Time Source: a later message cancels a quiet condition before it can produce work', () => {++ const e = engine(); e.s.sources.clock = { kind: 'time', enabled: true, self: 'agent', scopes: ['quiet'], receive_all: ['quiet'] };++ e.receive(trigger('a'));++ schedule(e, { id: 'quiet', source: 'clock', scope: 'quiet', due: 2000, text: 'No reply', quiet_thread: 'room', since_trigger: 'a' });++ e.receive(trigger('b')); e.now = 2000; tick(e);++ assert.equal(e.s.schedules.quiet.reason, 'conversation_changed'); assert.equal(Object.values(e.s.triggers).filter(t => t.source === 'clock').length, 0);++});++test('003 Channel: removing a destination drops a stored reply with a durable reason', () => {++ const e = engine(), k = accept(e);++ const reply = e.reply({ ticket_id: k.id, conversation: 'room', source: 'chat', key: 'ack', text: 'Acknowledged' });++ e.s.sources.chat.scopes = []; assert.equal(e.claim({ kinds: ['reply'] }), null);++ assert.equal(reply.state, 'dropped'); assert.equal(reply.reason, 'source_no_longer_listens');++});++test('003 Stored work: failed input delivery is visible and successful retry clears it', () => {++ const e = engine(), k = accept(e); let w = e.claim({ kinds: ['launch'] }); e.settle({ id: w.id, claim: w.claim, ok: true });++ e.receive(trigger('follow')); w = e.claim({ kinds: ['input'] }); e.settle({ id: w.id, claim: w.claim, ok: false });++ assert.equal(k.stage, 'person/delivery'); assert.equal(e.s.work.find(x => x.id === w.id).due, 3000);++ e.now = 3000; w = e.claim({ kinds: ['input'] }); e.settle({ id: w.id, claim: w.claim, ok: true }); assert.equal(k.stage, 'working');++});++test('001 Thread: out-of-order Source replay is presented in event-time order', () => {++ const e = engine(); e.receive(trigger('new', { occurred_at: '2026-11-09T13:00:00.000Z' })); e.receive(trigger('old'));++ assert.deepEqual(e.s.threads.room.triggers, ['old', 'new']); assert.deepEqual(e.start({ thread: 'room' }).ids, ['old', 'new']);++});++test('002 Revalidation: an explicit follow-up mandate survives allowed-list changes', () => {++ const e = engine(), first = accept(e); e.receive(trigger('follow', { addresses_agent: 'none' }));++ e.handBack({ ticket_id: first.id, trigger_id: 'follow', reason: 'Independent scope' });++ const ev = e.start({ thread: 'room' }), second = e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Follow-up' }).ticket;++ assert.equal(e.s.meta[second.id].basis, 'followup'); e.end({ ticket_id: second.id });++ const c = cfg(); c.policy.sources.chat.allowed = []; e.configure(c); e.receive(trigger('resume')); assert.equal(second.state, 'open');++});++test('Command boundary: inherited object names cannot identify Tickets or Threads', () => {++ const e = engine();++ for (const id of ['__proto__', 'constructor', 'prototype']) {++ assert.throws(() => e.end({ ticket_id: id })); assert.throws(() => e.start({ thread: id }));++ }++ assert.equal(Object.prototype.state, undefined);++});++console.log(`${count} fabric rule tests passed`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+# Actor work fabric (S4)
+
+`ACTOR_WORK_FABRIC=1` enables the backend. It is off by default. Each agent starts with `observe` policy, empty allowed lists and zero budget. There are no UI changes. Configured Sources take over matching events from the legacy Chat/Mail intake, so an event cannot launch through both paths. Unconfigured events retain the existing path.
+
+## Transaction and integration boundary
+
+`server/fabric/engine.js` has synchronous commands. `service.js` locks one agent's database aggregate, runs one command, then commits its state, source cursors and stored work together. A thrown validation error rolls everything back. Model and network calls run outside the transaction. Commands for different agents use different row locks. This initial implementation stores an agent's history as one JSONB aggregate: it favors atomicity and auditability over high-volume throughput; archival/partitioning is future work before high-volume rollout.
+
+`agent.trigger@1`, `agent.ticket@1` and `agent.decision@1` validate the durable domain records. Internal delivery leases, subscriptions and disposition metadata are separate from those wire records. The existing `server/task-view/tickets.js` is a different read model and is not replaced.
+
+An owner uses their `actor.agent.manage` credential:
+
+- `GET /api/v1/fabric/:agent` reads state and revision.
+- `POST /api/v1/fabric/:agent/commands/configure` stores `{sources, policy}`.
+- Commands `start`, `decide`, `attach`, `stop`, `end`, `subscribe`, `unsubscribe`, `handBack`, `read`, `reply`, `schedule`, `cancelSchedule`, `tick`, `claim`, `settle`, `delivered` expose the engine ports. Unknown commands and foreign agents are refused. Source intake is not a public command.
+
+Source configuration maps instance ids to `{kind, enabled, self, scopes, receive_all}`. Chat additionally takes `mentions` (names without @); Mail takes `mail_agent`. The service stamps `owner_subject` from the authenticated owner, ignoring caller-supplied identities. Scope names are `global`, `channel:<id>`, `dm:<id>`, `inbox:<id>`, or configured time scope names. DMs must include both the bound agent identity and the owner. Mail events must name the owner's configured inbox and agent. Mail reads the original through the existing authorized Mail read client before entering the transaction, checks the server-stamped owner, inbox and sender, and groups replies by the References root. Chat room reply chains form Threads; separate top-level mentions do not share ownership. Own messages and events outside scope are dropped before domain intake. DM Events contain identifiers rather than text: the Trigger preserves these facts with a read pointer; workers must read the original through their authorized Chat connection.
+
+Policies contain `mode` (`off|observe|auto`), `rules` (`id`, `enabled`, `instruction`), `budget` (`usd`, `tokens`, `wall_minutes`) and `sources`. Each Source policy has `allowed`, `bots` and an `admission` table keyed by scope, with `listed_unaddressed`, `unlisted_addressed` and `unlisted_unaddressed` cells. The first and last allow `inferred|ignore`; unlisted addressed also allows `explicit`. No identities are mapped between Sources.
+
+## Routing and workers
+
+A `start` command batches pending Triggers on one Thread. While that evaluation runs, new events form its next batch. Explicit candidates produce `route` work; inferred candidates produce `assess` work. The router only routes; it cannot replace a worker-owned follow-up with new work. It may stop a Ticket on an explicitly authorized person's message.
+
+The assessor returns `decision`, `scope`, `direction` and `rule_assessments`. Direction contains `implementation_requested`, `target_basis` (`specified|existing-standard|undetermined`), `requires_design_decision` and `evidence` entries `{trigger_id, quote}`. Every quote must occur in the original Trigger and every enabled rule must be assessed exactly once. Inferred acceptance needs settled direction and a matched rule. Observe records `ignore` with `would_accept`, with no launch or reply. The `ignored_from_card` shortcut can only ignore, with a reason. A missing/failed assessment should return `unavailable`; delivery starts the timeout, and three delivered expiries end unavailable after two retries.
+
+Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver later events directly through `input` work without changing authority. Human own-thread messages stay with their workers until all owners hand them back, once each with a reason. `context` subscriptions neither own messages nor resume ended Tickets. `attach` requires the message to explicitly name the Ticket. A worker cannot unsubscribe from origin or Source-bound Threads. `read` is called only after a step consumed the input and a report/turn end followed; ending releases unread messages. Full session-level read accounting and report/stage integration are S6.
+
+`end` ends a worker round; it does not withdraw authority. A person's follow-up can resume it after current-policy revalidation, while bots cannot. `end` with `person:true` is the owner's manual stop. `stop` checks the originating human Trigger's explicit gate strength. Stopped Tickets never resume. Policy changes discard pending evaluations and notify affected live workers without cancelling their mandates.
+
+## Stored work
+
+Adapters claim `route`, `assess`, `launch`, `input`, `end` or `reply` work, perform it after the claim commits, then settle using the claim token. `createFabric().drain(owner, agent, handlers)` provides this boundary for in-process adapters. Handlers receive a Ticket-bound worker port: it cannot change policy, accept work, claim a human stop, or create an authoritative subscription. No model/executor adapter is enabled automatically by the feature flag: routing/assessment/worker integrations consume these ports. A rollout needs authorized adapters connected before turning Sources on; otherwise work remains durably queued, and no model calls or spend occur.
+
+Each adapter must honor the stable idempotency key. Transport is at-least-once; a crash after sending and before settlement retries the same key. Expiring leases recover interrupted sends; stale lease tokens cannot settle newer claims. Retries start at 2 seconds and double to 60 seconds (replies: 5 to 300 seconds). Earlier work blocks later work in the same conversation. Other conversations proceed independently. Replies wait while their Source is off and drop only when its destination is no longer listened to or cannot receive. Drops retain their reason and time and cannot be requeued with the same key. Resume launches include a recovery instruction to inspect existing work before repeating outside actions.
+
+Time belongs to `sources.js`, not assessment. A schedule is `{id, source, scope, due, text, interval_ms?}`; timestamps are epoch milliseconds. A quiet condition adds `{quiet_thread, since_trigger}`. The Source cancels it if that Thread changes before its due time. Time produces immutable system Triggers; admission requires `clock` in the Source's bot list and the scope in `receive_all`. Time never grants explicit authority. `tick` checks due conditions, expires evaluations and starts pending batches; it never sleeps or calls a model.
+
+Account export/deletion includes the fabric aggregate. The expand-only migration adds no production writes during development. Tests name the corresponding rules from reference docs 001–003; `ov test test/fabric.test.js test/fabric-store.test.js` exercises policy, intake, persistence, ownership, routing and delivery without external services.
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- S4: an agent is the transaction boundary. Commands run synchronously on its locked aggregate;
+-- source cursors, immutable events, mandates and the ordered outbox commit together.
+-- Separate from actor_task_view tickets: those are a read model for existing runtime tasks.
+CREATE TABLE actor_work_fabric (
+ agent_id TEXT PRIMARY KEY REFERENCES actor_agents(id) ON DELETE CASCADE,
+ owner TEXT NOT NULL,
+ revision BIGINT NOT NULL DEFAULT 0,
+ state JSONB NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX actor_work_fabric_owner ON actor_work_fabric(owner);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1679,9 +1679,9 @@
@@ -1679,9 +1679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.373.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
- "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",
+ "version": "0.384.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -165,6 +165,12 @@ async function createApp(opts = {}) {
@@ -165,6 +165,12 @@ async function createApp(opts = {}) {
// An agent answers the mail sent to its own address (track HO6): the consumer below takes the event, the engine
// An agent answers the mail sent to its own address (track HO6): the consumer below takes the event, the engine
// reports each run's end back here, and the approvals decide() asks it to send an approved draft.
// reports each run's end back here, and the approvals decide() asks it to send an approved draft.
const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media, tips };
const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers, triggers, skillRun, media, tips };
+ const fabricMail = require('./mail/client').createMailClient({ config, fetchImpl: opts.mailFetch || fetchImpl, tokens: opts.mailTokens || null });
+ ctx.fabric = require('./fabric/service').createFabric({ s, enabled: !!config.fabric?.enabled, readMail: async (args) => {
+ const out = await fabricMail.getMessage(args);
+ if (!out.ok) throw new Error('Fabric Mail Source read unavailable');
+ return out.data;
+ } });
ctx.day = require('./day').createDay(ctx);
ctx.day = require('./day').createDay(ctx);
engine.setDay(ctx.day);
engine.setDay(ctx.day);
ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, triggers, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });
ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, triggers, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });
@@ -274,12 +280,16 @@ async function createApp(opts = {}) {
@@ -274,12 +280,16 @@ async function createApp(opts = {}) {
// chat.message.created goes to chat/asks.js (ask Actor from chat); anything else is acknowledged and ignored.
// chat.message.created goes to chat/asks.js (ask Actor from chat); anything else is acknowledged and ignored.
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log,
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log,
// A chat message asks Actor a question; anything else may wake a wait (plan A2).
// A chat message asks Actor a question; anything else may wake a wait (plan A2).
- onEvent: (ev) => (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev)
+ onEvent: async (ev) => {
+ const intake = await ctx.fabric.onEvent(ev);
+ if (intake.length) return 'fabric';
+ return (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev)
// The agent's steps (design 2026-11-07): every mode publishes agent.step.* with the person's subject.
// The agent's steps (design 2026-11-07): every mode publishes agent.step.* with the person's subject.
: String(ev.event_type || '').startsWith('agent.step.') ? timeline.onEvent(ctx.s, ev)
: String(ev.event_type || '').startsWith('agent.step.') ? timeline.onEvent(ctx.s, ev)
// A letter at an agent's own address (track HO6): that agent answers it, with the mail as data.
// A letter at an agent's own address (track HO6): that agent answers it, with the mail as data.
: ev.event_type === 'mail.agent_inbox.received' ? ctx.agentMail.onEvent(ev)
: ev.event_type === 'mail.agent_inbox.received' ? ctx.agentMail.onEvent(ev)
- : waits.onEvent(ev)) }));
+ : waits.onEvent(ev));
+ } }));
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -317,6 +327,7 @@ async function createApp(opts = {}) {
@@ -317,6 +327,7 @@ async function createApp(opts = {}) {
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
+ app.use('/api/v1/fabric', require('./fabric/api').createRoutes(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
// A2A (stage V2-11): another agent hands Actor a task. The card is /.well-known/agent.json, below.
// A2A (stage V2-11): another agent hands Actor a task. The card is /.well-known/agent.json, below.
app.use(createA2APages(ctx));
app.use(createA2APages(ctx));
@@ -28,6 +28,7 @@ function load(env = process.env) {
@@ -28,6 +28,7 @@ function load(env = process.env) {
return {
return {
service: 'actor',
service: 'actor',
+ fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
nodeEnv,
nodeEnv,
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+const express = require('express');
+const { asyncRouter } = require('../http/router');
+const contracts = require('openvibe-contracts');
+function createRoutes(ctx) {
+ const r = asyncRouter();
+ r.use(express.json({ limit: '64kb' }));
+ r.use(ctx.principal.middleware);
+ r.use(ctx.principal.requireCapability('actor.agent.manage'));
+ r.use((req, res, next) => {
+ if (!ctx.fabric.enabled) return res.sendStatus(404);
+ if (req.principal?.kind !== 'user') return res.sendStatus(403);
+ res.set('Cache-Control', 'no-store'); next();
+ });
+ const run = fn => async (req, res, next) => {
+ try { res.json(await fn(req)); }
+ catch (err) { if (err.status) return contracts.http.sendProblem(res, err.status, 'actor.fabric.refused', { detail: err.message, ctx: req.ov }); next(err); }
+ };
+ r.get('/:agent', run(req => ctx.fabric.snapshot(req.principal.requester, req.params.agent)));
+ r.post('/:agent/commands/:command', run(req => ctx.fabric.command(req.principal.requester, req.params.agent, req.params.command, req.body)));
+ return r;
+}
+module.exports = { createRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
+'use strict';
+const { policy, gate, revalidate, assessment, assert, text } = require('./policy');
+const copy = structuredClone;
+const own = (map, key) => Object.hasOwn(map, key) ? map[key] : undefined;
+const emptyDirection = () => ({ implementation_requested: false, target_basis: 'undetermined', requires_design_decision: false, evidence: [] });
+function initial() {
+ return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0 };
+}
+// All methods are synchronous. The repository loads and locks an agent's aggregate, invokes exactly one command,
+// then commits its state and outbox together. No model, timer, promise or network port is reachable here.
+class Engine {
+ constructor(state, { now = Date.now(), validate = () => {} } = {}) { this.s = state; this.now = now; this.validate = validate; }
+ id(kind) { return `${kind}_${++this.s.seq}`; }
+ thread(key) { assert(text(key) && !['__proto__', 'constructor', 'prototype'].includes(key), 'Invalid Thread'); return this.s.threads[key] ||= { pending: [], subscriptions: {}, evaluation: null, history: [] }; }
+ queue(t) {
+ const th = this.thread(t.threads[0]);
+ if (!this.owners(t.id).length && !th.pending.includes(t.id) && !th.evaluation?.ids.includes(t.id) && !Object.hasOwn(this.s.accepted, t.id)) th.pending.push(t.id);
+ }
+ owners(id) { return Object.entries(this.s.meta).filter(([, m]) => m.owned.includes(id)).map(([k]) => k); }
+ enqueue(kind, key, payload, conversation = key) {
+ const old = this.s.work.find(w => w.key === key);
+ if (old) return old;
+ const w = { id: this.id('work'), key, kind, payload: copy(payload), conversation, state: 'pending', attempts: 0, due: this.now };
+ this.s.work.push(w); return w;
+ }
+ configure({ sources, policy: next }) {
+ if (sources) {
+ for (const [id, s] of Object.entries(sources)) {
+ assert(/^[a-zA-Z0-9_-]{1,80}$/.test(id) && !['__proto__', 'constructor', 'prototype'].includes(id) && ['chat', 'mail', 'time'].includes(s.kind) && Array.isArray(s.scopes) && s.scopes.every(text) && Array.isArray(s.receive_all || []) && (s.receive_all || []).every(x => s.scopes.includes(x)), 'Invalid Source configuration');
+ assert(text(s.self), 'Source needs its own bot identity');
+ assert(typeof s.enabled === 'boolean', 'Source needs enabled flag');
+ }
+ this.s.sources = copy(sources);
+ }
+ if (!next) return this.s.policy;
+ const old = this.s.policy, p = policy(next);
+ if (old.version === p.version) return p;
+ this.s.policy = p;
+ for (const th of Object.values(this.s.threads)) if (th.evaluation) {
+ const ids = th.evaluation.ids;
+ this.finish(th, 'unavailable', { reason: 'policy_changed' });
+ for (const id of ids) this.queue(this.s.triggers[id]);
+ }
+ for (const [id, k] of Object.entries(this.s.tickets)) {
+ if (k.state !== 'open') continue;
+ const m = k.mandate;
+ if (old.mode !== p.mode || JSON.stringify(old.sources[m.actor.source]) !== JSON.stringify(p.sources[m.actor.source]) || m.matched_rules.some(r => JSON.stringify(old.rules.find(x => x.id === r)) !== JSON.stringify(p.rules.find(x => x.id === r)))) {
+ this.enqueue('input', `policy:${id}:${p.version}`, { ticket_id: id, policy: p, previous_version: old.version }, `worker:${id}`);
+ }
+ }
+ return p;
+ }
+ receive(t) {
+ this.validate('agent.trigger', t);
+ assert([t.id, ...t.threads].every(id => text(id) && !['__proto__', 'constructor', 'prototype'].includes(id)), 'Invalid event or thread id');
+ assert(t.actor.source === t.source, 'Identity is Source-local');
+ const source = this.s.sources[t.source];
+ if (!source || !source.enabled || !source.scopes.includes(t.source_scope) || t.actor.id === source.self) return { status: 'filtered' };
+ if (Object.hasOwn(this.s.triggers, t.id)) return { status: 'duplicate' };
+ this.s.triggers[t.id] = copy(t);
+ const reached = new Map();
+ for (const key of t.threads) {
+ const th = this.thread(key);
+ th.last_trigger_id = t.id;
+ th.triggers ||= []; th.triggers.push(t.id);
+ th.triggers.sort((a, b) => Date.parse(this.s.triggers[a].occurred_at) - Date.parse(this.s.triggers[b].occurred_at) || a.localeCompare(b));
+ for (const [id, sub] of Object.entries(th.subscriptions)) reached.set(id, (reached.get(id) || false) || sub !== 'context');
+ // Correlation is recorded for reads only; it is never traversed by fan-out.
+ th.correlated = [...new Set([...(th.correlated || []), ...t.threads.filter(x => x !== key)])];
+ }
+ for (const [id, own] of reached) {
+ const k = this.s.tickets[id], m = this.s.meta[id];
+ if (m.stopped || m.revoked) continue;
+ if (k.state === 'ended') {
+ if (t.actor.kind !== 'person' || !own) continue;
+ if (!revalidate(k, m, this.s.triggers[k.trigger_id], this.s.policy, this.s.sources)) { m.revoked = true; continue; }
+ k.state = 'open'; k.stage = 'working/queued';
+ this.enqueue('launch', `resume:${id}:${t.id}`, { ticket: k, recovery: 'Check existing work before repeating any outside action.' }, `worker:${id}`);
+ }
+ if (t.actor.kind === 'person' && own) m.owned.push(t.id);
+ this.enqueue('input', `input:${id}:${t.id}`, { ticket_id: id, trigger: t, context_only: !own }, `worker:${id}`);
+ }
+ this.queue(t);
+ return { status: 'stored', owners: this.owners(t.id) };
+ }
+ start({ thread }) {
+ const th = this.thread(thread);
+ if (th.evaluation) return copy(th.evaluation);
+ const ids = th.pending.filter(id => !this.owners(id).length && !Object.hasOwn(this.s.accepted, id));
+ ids.sort((a, b) => Date.parse(this.s.triggers[a].occurred_at) - Date.parse(this.s.triggers[b].occurred_at) || a.localeCompare(b));
+ if (!ids.length) { th.pending = []; return null; }
+ const candidates = ids.map(id => {
+ const t = this.s.triggers[id];
+ const follow = Object.values(this.s.tickets).some(k => k.threads.includes(thread) && this.thread(thread).subscriptions[k.id] !== 'context');
+ return { id, result: gate(t, this.s.policy, this.s.sources[t.source], follow) };
+ }).filter(x => x.result);
+ candidates.sort((a, b) => Number(b.result.strength === 'explicit') - Number(a.result.strength === 'explicit'));
+ const best = candidates[0];
+ th.pending = [];
+ th.evaluation = { id: this.id('eval'), ids, primary: best?.id || ids[0], strength: best?.result.strength || 'inferred', basis: best?.result.basis, policy_version: this.s.policy.version, attempts: th.expiries || 0 };
+ if (!best) { this.finish(th, 'ignore', { reason: 'not_admitted' }); return null; }
+ const e = copy(th.evaluation);
+ // Explicit candidates go to the deterministic routing port, never the assessor.
+ this.enqueue(e.strength === 'explicit' ? 'route' : 'assess', `candidate:${e.id}`, { evaluation: e, triggers: ids.map(id => this.s.triggers[id]), tickets: Object.values(this.s.tickets).filter(k => k.threads.includes(thread)), policy: this.s.policy, handbacks: Object.values(this.s.meta).flatMap(m => m.handbacks).filter(h => ids.includes(h.trigger_id)) }, `router:${thread}`);
+ return e;
+ }
+ finish(th, decision, extra = {}) {
+ const e = th.evaluation;
+ const d = { id: this.id('decision'), trigger_ids: e.ids, policy_version: e.policy_version, decision, strength: e.strength, direction: emptyDirection(), rule_assessments: [], ...extra };
+ this.validate('agent.decision', d);
+ this.s.decisions.push(d); th.history.push(d.id); th.evaluation = null;
+ for (const w of this.s.work) if (w.key === `candidate:${e.id}` && w.state !== 'done') { w.state = 'dropped'; w.reason = 'evaluation_finished'; w.finished_at = this.now; }
+ return d;
+ }
+ decide({ evaluation, decision, scope, assessment: a, reason, ignored_from_card = false }) {
+ const th = Object.values(this.s.threads).find(t => t.evaluation?.id === evaluation);
+ assert(th, 'Evaluation is no longer current');
+ const e = th.evaluation;
+ assert(e.policy_version === this.s.policy.version, 'Policy changed');
+ assert(['accept', 'ignore', 'unavailable'].includes(decision), 'Invalid routing decision');
+ let matched = [], audit = { reason: reason || decision };
+ if (e.strength === 'inferred' && !ignored_from_card && decision !== 'unavailable') {
+ const result = assessment(a || {}, this.s.policy, e.ids.map(id => this.s.triggers[id]));
+ matched = result.matched;
+ decision = a.decision === 'unavailable' ? 'unavailable' : result.accepted ? 'accept' : 'ignore';
+ scope = a.scope;
+ audit = { ...audit, direction: a.direction, rule_assessments: a.rule_assessments };
+ } else if (ignored_from_card) {
+ assert(e.strength === 'inferred' && decision === 'ignore' && text(reason), 'Card shortcut only ignores inferred work with a reason');
+ audit.ignored_from_card = true;
+ } else assert(!a, 'Explicit candidates do not use assessment');
+ if (decision === 'accept' && e.strength === 'inferred' && this.s.policy.mode !== 'auto') { decision = 'ignore'; audit.would_accept = true; }
+ let ticket;
+ if (decision === 'accept') {
+ assert(text(scope), 'Acceptance needs a scope');
+ assert(!e.ids.some(id => this.owners(id).length), 'Worker owns follow-up');
+ const t = this.s.triggers[e.primary];
+ ticket = { id: this.id('ticket'), trigger_id: t.id, mandate: { policy_version: e.policy_version, scope, strength: e.strength, matched_rules: matched, actor: copy(t.actor), source_scope: t.source_scope, budget: copy(this.s.policy.budget) }, state: 'open', stage: 'working/queued', threads: [...t.threads] };
+ this.validate('agent.ticket', ticket);
+ this.s.tickets[ticket.id] = ticket;
+ for (const id of e.ids) this.s.accepted[id] = ticket.id;
+ this.s.meta[ticket.id] = { basis: e.basis, owned: [], handbacks: [], read: [] };
+ for (const key of t.threads) this.thread(key).subscriptions[ticket.id] = 'origin';
+ this.enqueue('launch', `launch:${ticket.id}`, { ticket, triggers: e.ids.map(id => this.s.triggers[id]) }, `worker:${ticket.id}`);
+ }
+ th.expiries = 0;
+ return { decision: this.finish(th, decision, audit), ...(ticket ? { ticket } : {}) };
+ }
+ subscribe({ ticket_id, thread, kind = 'context' }) {
+ const k = own(this.s.tickets, ticket_id);
+ assert(k && k.state === 'open', 'Ticket must be open');
+ assert(['context', 'attached', 'source'].includes(kind), 'Invalid subscription');
+ // Attaching is an explicit router command; callers must supply the pointing trigger through attach().
+ assert(kind !== 'attached', 'Use attach with an explicit ticket reference');
+ this.thread(thread).subscriptions[ticket_id] ||= kind;
+ if (!k.threads.includes(thread)) k.threads.push(thread);
+ return k;
+ }
+ attach({ ticket_id, trigger_id }) {
+ const k = own(this.s.tickets, ticket_id), t = own(this.s.triggers, trigger_id), m = own(this.s.meta, ticket_id);
+ assert(k && t && t.actor.kind === 'person' && t.text.includes(ticket_id), 'Message must explicitly point at the Ticket');
+ assert(!m.stopped && !m.revoked && !m.handbacks.some(h => h.trigger_id === trigger_id) && !this.owners(trigger_id).length, 'Message cannot be attached');
+ assert(k.state === 'open' || revalidate(k, m, this.s.triggers[k.trigger_id], this.s.policy, this.s.sources), 'Mandate no longer holds');
+ for (const key of t.threads) { this.thread(key).subscriptions[ticket_id] = 'attached'; if (!k.threads.includes(key)) k.threads.push(key); }
+ if (k.state === 'ended') { k.state = 'open'; this.enqueue('launch', `resume:${ticket_id}:${trigger_id}`, { ticket: k }, `worker:${ticket_id}`); }
+ m.owned.push(trigger_id);
+ for (const th of Object.values(this.s.threads)) {
+ th.pending = th.pending.filter(id => id !== trigger_id);
+ if (th.evaluation?.ids.includes(trigger_id)) {
+ const rest = th.evaluation.ids.filter(id => id !== trigger_id);
+ this.finish(th, 'ignore', { reason: 'attached' });
+ rest.forEach(id => this.queue(this.s.triggers[id]));
+ }
+ }
+ this.enqueue('input', `attach:${ticket_id}:${trigger_id}`, { ticket_id, trigger: t }, `worker:${ticket_id}`);
+ return k;
+ }
+ unsubscribe({ ticket_id, thread, person = false }) {
+ const th = this.thread(thread), kind = th.subscriptions[ticket_id];
+ if (person && !ticket_id) { th.subscriptions = {}; return true; }
+ assert(person || !['origin', 'source'].includes(kind), 'Worker cannot drop origin or source subscription');
+ delete th.subscriptions[ticket_id];
+ // Ownership survives removal. Thread membership remains historical for audit.
+ return true;
+ }
+ handBack({ ticket_id, trigger_id, reason }) {
+ const m = own(this.s.meta, ticket_id);
+ assert(m && text(reason), 'Hand-back needs a Ticket and reason');
+ if (m.handbacks.some(h => h.trigger_id === trigger_id) || !m.owned.includes(trigger_id)) return false;
+ m.handbacks.push({ ticket_id, trigger_id, reason, at: this.now });
+ m.owned = m.owned.filter(id => id !== trigger_id);
+ this.queue(own(this.s.triggers, trigger_id));
+ return true;
+ }
+ read({ ticket_id, trigger_ids }) {
+ const m = own(this.s.meta, ticket_id); assert(m, 'Unknown Ticket');
+ assert(Array.isArray(trigger_ids), 'Read needs trigger ids');
+ // Worker calls this after a report/turn end, not merely after transport delivery.
+ for (const id of trigger_ids) { assert(m.owned.includes(id), 'Worker did not own input'); if (!m.read.includes(id)) m.read.push(id); }
+ return true;
+ }
+ end({ ticket_id, reason = 'completed', person = false }) {
+ const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);
+ assert(k, 'Unknown Ticket'); k.state = 'ended'; m.ended_reason = reason;
+ if (person) m.stopped = true;
+ const unread = m.owned.filter(id => !m.read.includes(id)); m.owned = [];
+ for (const id of unread) this.queue(this.s.triggers[id]);
+ for (const w of this.s.work) if (w.conversation === `worker:${ticket_id}` && !['done', 'dropped'].includes(w.state)) { w.state = 'dropped'; w.reason = 'ticket_ended'; w.finished_at = this.now; }
+ this.enqueue('end', `end:${ticket_id}:${this.s.seq}`, { ticket_id, reason }, `worker:${ticket_id}`);
+ return k;
+ }
+ stop({ ticket_id, trigger_id, reason }) {
+ const t = own(this.s.triggers, trigger_id), k = own(this.s.tickets, ticket_id);
+ assert(t && k && t.threads.some(key => k.threads.includes(key)), 'Stop needs a message on the Ticket');
+ const g = gate(t, this.s.policy, this.s.sources[t.source], true);
+ assert(t.actor.kind === 'person' && g?.strength === 'explicit', 'Only explicit human stop withdraws a Mandate');
+ const m = own(this.s.meta, ticket_id); m.read.push(trigger_id);
+ this.end({ ticket_id, reason, person: true });
+ for (const th of Object.values(this.s.threads)) th.pending = th.pending.filter(id => id !== trigger_id);
+ const d = { id: this.id('decision'), trigger_ids: [trigger_id], policy_version: this.s.policy.version, decision: 'stop', strength: 'explicit', direction: emptyDirection(), rule_assessments: [], reason: reason || 'person_stop' };
+ this.validate('agent.decision', d); this.s.decisions.push(d); return k;
+ }
+ delivered({ evaluation }) {
+ const e = Object.values(this.s.threads).find(t => t.evaluation?.id === evaluation)?.evaluation;
+ if (e && !e.deadline) e.deadline = this.now + 300000;
+ }
+ expire() {
+ for (const th of Object.values(this.s.threads)) if (th.evaluation?.deadline <= this.now) {
+ const ids = th.evaluation.ids;
+ th.expiries = (th.expiries || 0) + 1;
+ if (th.expiries <= 2) {
+ const evalId = th.evaluation.id; th.evaluation = null;
+ for (const w of this.s.work) if (w.key === `candidate:${evalId}` && w.state !== 'done') { w.state = 'dropped'; w.reason = 'assessment_timeout_retry'; w.finished_at = this.now; }
+ ids.forEach(id => this.queue(this.s.triggers[id]));
+ } else this.finish(th, 'unavailable', { reason: 'assessment_timeout' });
+ }
+ }
+ reply({ ticket_id, conversation, key, text: body, source }) {
+ assert(own(this.s.tickets, ticket_id) && this.s.sources[source] && text(body) && text(conversation) && text(key), 'Invalid reply');
+ return this.enqueue('reply', `reply:${ticket_id}:${key}`, { ticket_id, text: body, source, source_scope: conversation }, `reply:${ticket_id}:${conversation}`);
+ }
+ claim({ kinds, lease_ms = 30000 }) {
+ assert(Array.isArray(kinds) && Number.isFinite(lease_ms) && lease_ms >= 1000 && lease_ms <= 300000, 'Invalid work claim');
+ const pending = this.s.work.filter(w => !['done', 'dropped'].includes(w.state));
+ for (const w of pending) {
+ if (!kinds.includes(w.kind) || w.due > this.now || (w.state === 'running' && w.lease_until > this.now)) continue;
+ if (pending.some(earlier => earlier.conversation === w.conversation && this.s.work.indexOf(earlier) < this.s.work.indexOf(w))) continue;
+ if (w.kind === 'reply') {
+ const source = this.s.sources[w.payload.source];
+ if (!source || !source.scopes.includes(w.payload.source_scope)) { w.state = 'dropped'; w.reason = 'source_no_longer_listens'; w.finished_at = this.now; continue; }
+ if (!source.enabled) continue;
+ }
+ w.state = 'running'; w.claim = this.id('claim'); w.lease_until = this.now + lease_ms;
+ return copy(w);
+ }
+ return null;
+ }
+ settle({ id, claim, ok, permanent_reason }) {
+ const w = this.s.work.find(x => x.id === id);
+ assert(w && w.claim === claim, 'Stale work claim');
+ if (w.state === 'dropped') return w;
+ assert(w.state === 'running', 'Stale work claim');
+ if (ok) {
+ w.state = 'done'; w.finished_at = this.now; delete w.last_error;
+ if (w.kind === 'input') {
+ const k = this.s.tickets[w.payload.ticket_id];
+ if (k?.state === 'open' && k.stage === 'person/delivery') k.stage = 'working';
+ }
+ if (w.kind === 'launch') {
+ const k = this.s.tickets[w.payload.ticket.id];
+ if (k?.state === 'open') k.stage = 'working';
+ }
+ if (['route', 'assess'].includes(w.kind)) this.delivered({ evaluation: w.payload.evaluation.id });
+ } else if (permanent_reason) {
+ assert(w.kind === 'reply' && ['source_no_longer_listens', 'destination_cannot_receive'].includes(permanent_reason), 'Invalid permanent drop');
+ w.state = 'dropped'; w.reason = permanent_reason; w.finished_at = this.now;
+ } else {
+ w.attempts++; w.state = 'pending'; w.last_error = 'delivery_failed';
+ if (w.kind === 'input') {
+ const k = this.s.tickets[w.payload.ticket_id];
+ if (k?.state === 'open') k.stage = 'person/delivery';
+ }
+ w.due = this.now + Math.min(w.kind === 'reply' ? 300000 : 60000, (w.kind === 'reply' ? 5000 : 2000) * 2 ** Math.min(w.attempts - 1, 20));
+ }
+ return w;
+ }
+}
+module.exports = { Engine, initial };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+const { createHash } = require('node:crypto');
+const assert = (ok, message) => { if (!ok) throw Object.assign(new Error(message), { status: 422 }); };
+const text = (s) => typeof s === 'string' && s.trim().length > 0;
+function policy(input = {}) {
+ const p = structuredClone({ mode: 'observe', rules: [], sources: {}, budget: { usd: 0, tokens: 0, wall_minutes: 0 }, ...input });
+ delete p.version;
+ assert(['off', 'observe', 'auto'].includes(p.mode), 'Invalid accept mode');
+ assert(Array.isArray(p.rules) && p.rules.every(r => text(r.id) && text(r.instruction) && typeof r.enabled === 'boolean') && new Set(p.rules.map(r => r.id)).size === p.rules.length, 'Rules need unique ids, instructions and enabled flags');
+ assert(p.budget && ['usd', 'tokens', 'wall_minutes'].every(k => Number.isFinite(p.budget[k]) && p.budget[k] >= 0) && Number.isInteger(p.budget.tokens), 'Invalid mandate budget');
+ assert(p.sources && typeof p.sources === 'object' && !Array.isArray(p.sources), 'Invalid source policies');
+ for (const s of Object.values(p.sources)) {
+ assert(Array.isArray(s.allowed) && Array.isArray(s.bots) && [...s.allowed, ...s.bots].every(text), 'Source identities must be lists');
+ for (const row of Object.values(s.admission || {})) {
+ assert(['inferred', 'ignore'].includes(row.listed_unaddressed || 'inferred'), 'Invalid listed admission');
+ assert(['explicit', 'inferred', 'ignore'].includes(row.unlisted_addressed || 'ignore'), 'Invalid addressed admission');
+ assert(['inferred', 'ignore'].includes(row.unlisted_unaddressed || 'ignore'), 'Invalid unaddressed admission');
+ }
+ }
+ // Version covers every nested policy value, including budget; object insertion order is normalized recursively.
+ const stable = v => Array.isArray(v) ? v.map(stable) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, stable(v[k])])) : v;
+ p.version = createHash('sha256').update(JSON.stringify(stable(p))).digest('hex');
+ return p;
+}
+function gate(t, p, source, followup = false) {
+ const s = p.sources[t.source];
+ if (!s || !source || source.enabled === false || !source.scopes.includes(t.source_scope)) return null;
+ const addressed = ['mention', 'direct'].includes(t.addresses_agent);
+ const all = (source.receive_all || []).includes(t.source_scope);
+ const listed = s.allowed.includes(t.actor.id);
+ const bot = t.actor.kind !== 'person' || s.bots.includes(t.actor.id);
+ let strength, basis;
+ if (bot) {
+ if (!s.bots.includes(t.actor.id) || !(addressed || all)) return null;
+ strength = 'inferred'; basis = 'bot';
+ } else if (listed && addressed) { strength = 'explicit'; basis = 'addressed'; }
+ else {
+ const row = (s.admission || {})[t.source_scope] || {};
+ const cell = listed ? 'listed_unaddressed' : addressed ? 'unlisted_addressed' : 'unlisted_unaddressed';
+ const admission = (addressed || all) ? row[cell] || (listed ? 'inferred' : 'ignore') : 'ignore';
+ if (admission === 'explicit') { strength = 'explicit'; basis = 'table'; }
+ else if (followup) { strength = listed ? 'explicit' : 'inferred'; basis = 'followup'; }
+ else if (admission === 'inferred') { strength = 'inferred'; basis = 'table'; }
+ else return null;
+ }
+ return strength === 'inferred' && p.mode === 'off' ? null : { strength, basis };
+}
+function revalidate(ticket, meta, original, p, sources) {
+ const m = ticket.mandate, source = sources[original.source];
+ if (!source || source.enabled === false || !source.scopes.includes(original.source_scope)) return false;
+ if (m.strength === 'inferred' && (p.mode !== 'auto' || m.matched_rules.some(id => !p.rules.some(r => r.id === id && r.enabled)))) return false;
+ if (meta.basis === 'followup' && m.strength === 'explicit') return true;
+ if (meta.basis === 'addressed' && !p.sources[original.source]?.allowed.includes(m.actor.id)) return false;
+ const g = gate(original, p, source, meta.basis === 'followup');
+ return !!g && (g.strength === 'explicit' || m.strength === 'inferred');
+}
+function assessment(a, p, triggers) {
+ const rules = p.rules.filter(r => r.enabled), judged = a.rule_assessments;
+ assert(Array.isArray(judged) && judged.length === rules.length && new Set(judged.map(r => r.rule_id)).size === rules.length && rules.every(r => judged.some(j => j.rule_id === r.id)), 'Exactly one assessment per enabled rule');
+ assert(judged.every(r => typeof r.matched === 'boolean' && text(r.reason)), 'Invalid rule assessment');
+ const d = a.direction;
+ assert(d && typeof d.implementation_requested === 'boolean' && typeof d.requires_design_decision === 'boolean' && ['specified', 'existing-standard', 'undetermined'].includes(d.target_basis) && Array.isArray(d.evidence), 'Invalid structured direction');
+ assert(d.evidence.every(e => text(e.quote) && triggers.some(t => t.id === e.trigger_id && t.text.includes(e.quote))), 'Evidence must quote a trigger verbatim');
+ assert(['accept', 'ignore', 'unavailable'].includes(a.decision), 'Invalid assessment decision');
+ const matched = judged.filter(r => r.matched).map(r => r.rule_id);
+ const accepted = a.decision === 'accept' && d.implementation_requested && d.target_basis !== 'undetermined' && !d.requires_design_decision && d.evidence.length > 0 && matched.length > 0;
+ if (accepted) assert(text(a.scope), 'Acceptance needs a scope');
+ return { matched, accepted };
+}
+module.exports = { policy, gate, revalidate, assessment, assert, text };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+const contracts = require('openvibe-contracts');
+const { Engine, initial } = require('./engine');
+const sources = require('./sources');
+const { assert } = require('./policy');
+const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered']);
+function createFabric({ s, enabled = false, readMail, validate = (name, value) => {
+ try { return contracts.assertValid(`${name}@1`, value); }
+ catch (err) { if (err.code === 'contract.invalid') err.status = 422; throw err; }
+} }) {
+ const enabledCheck = () => { if (!enabled) throw Object.assign(new Error('Work fabric is disabled'), { status: 404 }); };
+ async function transact(owner, agent, command) {
+ enabledCheck();
+ return s.db.tx(async db => {
+ // Authorization is inside the transaction and is repeated for every command, including leases.
+ const exists = await db.maybe('SELECT id FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);
+ if (!exists) throw Object.assign(new Error('Agent not found'), { status: 404 });
+ await db.query('INSERT INTO actor_work_fabric (agent_id, owner, state) VALUES ($1,$2,$3::jsonb) ON CONFLICT DO NOTHING', [agent, owner, JSON.stringify(initial())]);
+ const row = await db.one('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);
+ const engine = new Engine(row.state, { now: s.now(), validate });
+ const result = command(engine);
+ assert(!result || typeof result.then !== 'function', 'Engine commands must be synchronous');
+ await db.query('UPDATE actor_work_fabric SET state = $3::jsonb, revision = revision + 1, updated_at = $4 WHERE agent_id = $1 AND owner = $2', [agent, owner, JSON.stringify(engine.s), s.iso()]);
+ return structuredClone(result ?? null);
+ });
+ }
+ return {
+ enabled,
+ async snapshot(owner, agent) {
+ enabledCheck();
+ const row = await s.db.maybe('SELECT f.state, f.revision FROM actor_work_fabric f JOIN actor_agents a ON a.id = f.agent_id WHERE f.agent_id = $1 AND f.owner = $2 AND a.owner = $2', [agent, owner]);
+ if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });
+ return row;
+ },
+ command(owner, agent, name, args = {}) {
+ assert(COMMANDS.has(name) || ['schedule', 'cancelSchedule', 'tick'].includes(name), 'Unknown fabric command');
+ return transact(owner, agent, engine => {
+ if (name === 'schedule') return sources.schedule(engine, args);
+ if (name === 'cancelSchedule') { assert(Object.hasOwn(engine.s.schedules, args.id), 'Unknown schedule'); engine.s.schedules[args.id].cancelled = true; return true; }
+ if (name === 'tick') return sources.tick(engine);
+ if (name === 'configure' && args.sources) {
+ args = structuredClone(args);
+ for (const [id, source] of Object.entries(args.sources)) {
+ source.owner_subject = owner.replace(/^user:/, '');
+ delete source.cursor; delete source.message_threads;
+ const old = engine.s.sources[id];
+ if (old?.kind === source.kind) { source.cursor = old.cursor; source.message_threads = old.message_threads; }
+ }
+ }
+ return engine[name](args);
+ });
+ },
+ worker(owner, agent, ticket) {
+ const allowed = new Set(['handBack', 'read', 'end', 'subscribe', 'unsubscribe', 'reply']);
+ return {
+ command: (name, args = {}) => {
+ assert(allowed.has(name), 'Command is not available to a worker');
+ // Worker ports cannot claim a human stop, change policy or create an authoritative binding.
+ const safe = { ...args, ticket_id: ticket, person: false };
+ if (name === 'subscribe') safe.kind = 'context';
+ return this.command(owner, agent, name, safe);
+ },
+ };
+ },
+ async onEvent(event) {
+ if (!enabled || !sources.TOPICS.includes(event.event_type)) return [];
+ const rows = await s.db.many('SELECT agent_id, owner, state FROM actor_work_fabric ORDER BY agent_id');
+ const out = [];
+ for (const row of rows) {
+ let letter;
+ if (event.event_type === 'mail.agent_inbox.received') {
+ const p = event.payload || {};
+ const matches = Object.values(row.state.sources).some(x => x.kind === 'mail' && x.enabled && x.owner_subject === p.owner && x.mail_agent === p.agent_id && x.scopes.includes(`inbox:${p.inbox_id}`));
+ if (!matches) continue;
+ assert(typeof readMail === 'function', 'Mail Source needs its authorized read port');
+ letter = await readMail({ person: p.owner, agentId: p.agent_id, messageId: p.message_id });
+ contracts.assertValid('mail.agent-inbox-message@1', letter);
+ if (letter.spam) { out.push({ status: 'filtered' }); continue; }
+ }
+ out.push(...await transact(row.owner, row.agent_id, e => sources.receiveEvent(e, event, letter)));
+ }
+ return out;
+ },
+ async tick() {
+ if (!enabled) return;
+ const rows = await s.db.many('SELECT agent_id, owner FROM actor_work_fabric ORDER BY agent_id');
+ for (const row of rows) await transact(row.owner, row.agent_id, sources.tick);
+ },
+ // An injected adapter performs work only AFTER claim commits. Stable keys are required at the receiver:
+ // transport is at-least-once, and a crash after send but before settle retries the same key.
+ async drain(owner, agent, handlers) {
+ const work = await this.command(owner, agent, 'claim', { kinds: Object.keys(handlers) });
+ if (!work) return false;
+ try {
+ const ticket = work.payload.ticket?.id || work.payload.ticket_id;
+ await handlers[work.kind](work.payload, { idempotency_key: work.key, ...(ticket ? { worker: this.worker(owner, agent, ticket) } : {}) });
+ await this.command(owner, agent, 'settle', { id: work.id, claim: work.claim, ok: true });
+ } catch {
+ await this.command(owner, agent, 'settle', { id: work.id, claim: work.claim, ok: false });
+ }
+ return true;
+ },
+ };
+}
+module.exports = { createFabric };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+const { createHash } = require('node:crypto');
+const { assert, text } = require('./policy');
+const key = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');
+const TOPICS = ['chat.message.created', 'chat.dm.created', 'mail.agent_inbox.received'];
+// Plugins consume verified Events envelopes only. Source instance bindings are owner configuration, never
+// inferred from arbitrary event text. The cursor and resulting Trigger commit in the same transaction.
+const plugins = {
+ chat(event, source) {
+ const p = event.payload || {}, dm = event.event_type === 'chat.dm.created';
+ if (!['chat.message.created', 'chat.dm.created'].includes(event.event_type) || p.source_platform || !p.message_id) return null;
+ const scope = dm ? `dm:${p.conversation_id}` : p.room?.type === 'global' ? 'global' : `channel:${p.room?.channel_user_id}`;
+ if (!source.scopes.includes(scope)) return null;
+ const actor = dm ? p.sender_subject : p.user_subject;
+ if (!text(actor)) return null;
+ // An explicitly bound DM is a Source; it must include the agent's Chat identity as a participant.
+ if (dm && (!(p.participants || []).some(x => x.subject === source.self) || !(p.participants || []).some(x => x.subject === source.owner_subject))) return null;
+ const mentioned = typeof p.text === 'string' && (source.mentions || []).some(name => p.text.split(/\s+/).some(word => word.replace(/[,:!?]+$/, '') === `@${name}`));
+ if (!dm && !mentioned && !(source.receive_all || []).includes(scope)) return null;
+ return { event_id: `${event.event_type}:${p.message_id}`, scope, thread: dm ? scope : source.message_threads?.[`message_${p.reply_to_id}`] || `message_${p.reply_to_id || p.message_id}`, actor, kind: p.message_type === 'bot' || p.message_type === 'ai' ? 'bot' : 'person', address: dm ? 'direct' : mentioned ? 'mention' : 'none',
+ // DM events intentionally carry identifiers, not private message bodies. Preserve those source facts;
+ // an executor reads the message through Chat with its own authorized connection.
+ text: dm ? JSON.stringify(p) : p.text, pointer: `https://openvibe.chat/${dm ? `dm/${p.conversation_id}` : `messages/${p.message_id}`}` };
+ },
+ mail(event, source, letter) {
+ if (event.event_type !== 'mail.agent_inbox.received') return null;
+ const p = event.payload || {}, scope = `inbox:${p.inbox_id}`;
+ if (!source.scopes.includes(scope) || p.owner !== source.owner_subject || p.agent_id !== source.mail_agent || !text(p.message_id) || !text(p.from)) return null;
+ if (!letter || !Array.isArray(letter.from) || letter.from.length !== 1 || letter.from[0].trim().toLowerCase() !== p.from.trim().toLowerCase() || letter.spam || letter.owner !== source.owner_subject || letter.agent_id !== source.mail_agent || letter.inbox !== p.inbox) return null;
+ return { event_id: p.message_id, scope, thread: letter.references?.[0] || letter.in_reply_to || letter.message_id, actor: p.from.trim().toLowerCase(), kind: 'person', address: 'direct', text: letter.text || JSON.stringify({ message_id: p.message_id, subject: letter.subject || '' }), pointer: `https://openvibe.email/inbox/${encodeURIComponent(p.inbox_id)}/message/${encodeURIComponent(p.message_id)}` };
+ },
+ time(event, source) {
+ if (event.event_type !== 'actor.fabric.time' || !source.scopes.includes(event.payload.scope)) return null;
+ const p = event.payload;
+ return { event_id: `${p.schedule_id}:${p.due}`, scope: p.scope, actor: 'clock', kind: 'system', address: 'none', text: p.text };
+ },
+};
+function translate(event, id, source, now, letter) {
+ const raw = plugins[source.kind]?.(event, source, letter);
+ if (!raw || !text(raw.text) || raw.actor === source.self || !source.enabled) return null;
+ return { id: `trigger_${key(id, raw.event_id)}`, source: id, actor: { source: id, id: raw.actor, kind: raw.kind }, source_scope: raw.scope,
+ threads: [`thread_${key(id, raw.scope, raw.thread || raw.scope)}`], occurred_at: new Date(event.occurred_at || event.created_at || now).toISOString(), text: raw.text, addresses_agent: raw.address, ...(raw.pointer ? { read_pointer: raw.pointer } : {}) };
+}
+function receiveEvent(engine, event, letter) {
+ const results = [];
+ for (const [id, source] of Object.entries(engine.s.sources)) {
+ if (source.kind === 'time') continue; // only the local time Source may produce clock events
+ const t = translate(event, id, source, engine.now, letter);
+ if (t) {
+ results.push(engine.receive(t));
+ source.cursor = event.event_id || event.id || t.id;
+ if (source.kind === 'chat' && event.event_type === 'chat.message.created') {
+ source.message_threads ||= {};
+ const p = event.payload;
+ source.message_threads[`message_${p.message_id}`] = source.message_threads[`message_${p.reply_to_id}`] || `message_${p.reply_to_id || p.message_id}`;
+ }
+ }
+ }
+ return results;
+}
+function schedule(engine, spec) {
+ const source = engine.s.sources[spec.source];
+ assert(source?.kind === 'time' && source.scopes.includes(spec.scope), 'Unknown time Source scope');
+ assert(text(spec.id) && !['__proto__', 'constructor', 'prototype'].includes(spec.id) && text(spec.text) && Number.isFinite(spec.due) && spec.due >= engine.now && (spec.interval_ms == null || Number.isSafeInteger(spec.interval_ms) && spec.interval_ms >= 1000), 'Invalid schedule');
+ assert(!Object.hasOwn(engine.s.schedules, spec.id), 'Schedules are immutable; cancel and create a new id');
+ if (spec.quiet_thread) assert(engine.s.threads[spec.quiet_thread]?.last_trigger_id === spec.since_trigger, 'Quiet condition needs the latest Trigger on its Thread');
+ engine.s.schedules[spec.id] = structuredClone(spec); return spec;
+}
+function tick(engine) {
+ for (const s of Object.values(engine.s.schedules)) {
+ const source = engine.s.sources[s.source];
+ if (s.cancelled || s.finished || s.due > engine.now || !source?.enabled) continue;
+ if (s.quiet_thread && engine.s.threads[s.quiet_thread]?.last_trigger_id !== s.since_trigger) { s.cancelled = true; s.reason = 'conversation_changed'; continue; }
+ const event = { event_type: 'actor.fabric.time', occurred_at: new Date(s.due).toISOString(), payload: { schedule_id: s.id, due: s.due, scope: s.scope, text: s.text } };
+ const trigger = translate(event, s.source, source, engine.now);
+ if (trigger) engine.receive(trigger);
+ source.cursor = trigger?.id;
+ // One occurrence per pass. Catch-up is ordered and bounded; no waiting inside Eval.
+ if (s.interval_ms) s.due += s.interval_ms; else s.finished = true;
+ }
+ engine.expire();
+ for (const thread of Object.keys(engine.s.threads)) engine.start({ thread });
+}
+module.exports = { plugins, translate, receiveEvent, schedule, tick, TOPICS };
@@ -101,6 +101,7 @@ const TABLES = [
@@ -101,6 +101,7 @@ const TABLES = [
{ table: 'actor_agent_proposals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-suggestions.json' },
{ table: 'actor_agent_proposals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-suggestions.json' },
// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
+ { table: 'actor_work_fabric', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric.json', erase: 'delete' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.
// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.
// The document is their own text, and the link is theirs to stop sharing.
// The document is their own text, and the link is theirs to stop sharing.
@@ -67,8 +67,11 @@ async function start() {
@@ -67,8 +67,11 @@ async function start() {
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and
// ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
// ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '',
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '',
- topics: config.chat.asks ? ['chat.message.created'] : [] });
+ topics: [...(config.chat.asks ? ['chat.message.created'] : []), ...(ctx.fabric.enabled ? require('./fabric/sources').TOPICS : [])] });
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
+ if (ctx.fabric.enabled && !config.drill) {
+ extra.push(require('./utils/jobs').every('actor-fabric-time', 1000, () => ctx.fabric.tick(), { initialDelayMs: 1000, log: console }));
+ }
const dayJobs = ctx.day.startJobs();
const dayJobs = ctx.day.startJobs();
extra.push(() => { for (const stop of dayJobs) stop(); });
extra.push(() => { for (const stop of dayJobs) stop(); });
// The waits (plan A2): the deadline loop fires the waits whose time is up, and the sweep reads the topics
// The waits (plan A2): the deadline loop fires the waits whose time is up, and the sweep reads the topics
@@ -102,10 +102,9 @@ const SAME = { origin: 'https://openvibe.actor' };
@@ -102,10 +102,9 @@ const SAME = { origin: 'https://openvibe.actor' };
const edited = await t.get(`/tasks/${id}/assumptions`, { as: bea, form: { assumptions: 'Use EUR\nAsk before booking' }, headers: SAME });
const edited = await t.get(`/tasks/${id}/assumptions`, { as: bea, form: { assumptions: 'Use EUR\nAsk before booking' }, headers: SAME });
assert.strictEqual(edited.status, 303);
assert.strictEqual(edited.status, 303);
t.providers.state.runtime = 'tool';
t.providers.state.runtime = 'tool';
- // Up to 10 s: the follow-up turn runs after the hang is released, and a loaded CI runner took longer than
- // the 2.5 s this waited before (Actor #384 failed on it three times on 2026-11-09; it passes locally in 9 s).
+ // Up to 30 s: the follow-up turn can wait behind the runtime queue on a loaded CI runner.
let seen = false;
let seen = false;
- for (const until = Date.now() + 10000; !seen && Date.now() < until;) {
+ for (const until = Date.now() + 30000; !seen && Date.now() < until;) {
const events = await store.eventsAfter(s, id, 0);
const events = await store.eventsAfter(s, id, 0);
seen = events.some((e) => e.step === 'plan' && /Assumptions: Use EUR; Ask before booking/.test(String(e.text)));
seen = events.some((e) => e.step === 'plan' && /Assumptions: Use EUR; Ask before booking/.test(String(e.text)));
if (!seen) await new Promise((r) => setTimeout(r, 50));
if (!seen) await new Promise((r) => setTimeout(r, 50));
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+'use strict';
+const assert = require('node:assert/strict');
+const contracts = require('openvibe-contracts');
+const { Engine, initial } = require('../server/fabric/engine');
+// This is intentionally a hard dependency: the fabric must not enable against a Contracts pin
+// without the released agent.* schemas. Never replace this with local schemas or a branch pin.
+for (const name of ['trigger', 'ticket', 'decision']) assert(contracts.schema(`agent.${name}@1`));
+const e = new Engine(initial(), { now: 1, validate: (name, value) => contracts.assertValid(`${name}@1`, value) });
+e.configure({ sources: { chat: { kind: 'chat', enabled: true, scopes: ['room'], self: 'agent' } }, policy: { sources: { chat: { allowed: ['person'], bots: [], admission: {} } } } });
+e.receive({ id: 'original', source: 'chat', actor: { source: 'chat', id: 'person', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T00:00:00.000Z', text: 'Fix test', addresses_agent: 'mention' });
+const ev = e.start({ thread: 'room' });
+const out = e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Fix test' });
+assert.equal(out.ticket.mandate.policy_version, e.s.policy.version);
+assert.equal(out.ticket.stage, 'working/queued');
+console.log('001 Trigger, Ticket/Mandate and 002 Decision conform to released agent.*@1 schemas');
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { createFabric } = require('../server/fabric/service');
+const contacts = require('../server/agents/contacts');
+const config = require('../server/config');
+const A = 'usr_01JZ0000000000000000000AAA', B = 'usr_01JZ0000000000000000000BBB';
+(async () => {
+ const t = await boot({ env: { ACTOR_WORK_FABRIC: '1' } });
+ try {
+ const s = t.ctx.s;
+ const alice = t.network.addUser('alice'); alice.subject = A;
+ const bob = t.network.addUser('bob'); bob.subject = B;
+ await contacts.ensureVibe(s, t.config, `user:${A}`);
+ const agent = (await contacts.list(s, t.config, `user:${A}`))[0].id;
+ // Storage tests exercise rollback separately from the released-contract tests.
+ const f = createFabric({ s, enabled: true, validate: () => {} });
+ const c = { sources: { chat: { kind: 'chat', enabled: true, self: 'bot', scopes: ['global'], mentions: ['actor'] } }, policy: { sources: { chat: { allowed: [A], bots: [], admission: {} } } } };
+ await check('001 transaction: feature defaults off and owner is checked on every command/read', async () => {
+ assert.equal(config.load({}).fabric.enabled, false);
+ await assert.rejects(() => createFabric({ s }).command(`user:${A}`, agent, 'configure', c), /disabled/);
+ await assert.rejects(() => f.command(`user:${B}`, agent, 'configure', c), /not found/);
+ await f.command(`user:${A}`, agent, 'configure', c);
+ await assert.rejects(() => f.snapshot(`user:${B}`, agent), /not found/);
+ assert.equal((await f.snapshot(`user:${A}`, agent)).state.sources.chat.owner_subject, A);
+ });
+ const ev = { event_id: 'ev1', event_type: 'chat.message.created', payload: { message_id: 1, user_subject: A, text: '@actor fix a test', room: { type: 'global' } } };
+ await check('001 atomic intake: concurrent replays store once with one cursor and one Eval', async () => {
+ await Promise.all([f.onEvent(ev), f.onEvent(ev)]);
+ let snap = await f.snapshot(`user:${A}`, agent); assert.equal(Object.keys(snap.state.triggers).length, 1); assert.equal(snap.state.sources.chat.cursor, 'ev1');
+ const thread = Object.keys(snap.state.threads)[0];
+ const evaluations = await Promise.all([f.command(`user:${A}`, agent, 'start', { thread }), f.command(`user:${A}`, agent, 'start', { thread })]);
+ assert.equal(evaluations[0].id, evaluations[1].id);
+ const strict = createFabric({ s, enabled: true, validate: name => { if (name === 'agent.decision') throw new Error('validation rejected'); } });
+ await assert.rejects(() => strict.command(`user:${A}`, agent, 'decide', { evaluation: evaluations[0].id, decision: 'accept', scope: 'Fix test' }), /validation rejected/);
+ snap = await f.snapshot(`user:${A}`, agent); assert.equal(Object.keys(snap.state.tickets).length, 0); assert(!snap.state.work.some(w => w.kind === 'launch')); assert(snap.state.threads[thread].evaluation);
+ await f.command(`user:${A}`, agent, 'decide', { evaluation: evaluations[0].id, decision: 'accept', scope: 'Fix test' });
+ });
+ await check('003 Stored work: handler observes committed Ticket; failed handler retains work after restart', async () => {
+ let called = 0;
+ await f.drain(`user:${A}`, agent, { launch: async (_payload, meta) => {
+ const saved = await f.snapshot(`user:${A}`, agent); assert.equal(Object.keys(saved.state.tickets).length, 1); assert(meta.idempotency_key); called++; throw new Error('offline');
+ } });
+ assert.equal(called, 1);
+ const recovered = createFabric({ s, enabled: true });
+ const snap = await recovered.snapshot(`user:${A}`, agent), work = snap.state.work.find(w => w.kind === 'launch');
+ assert.equal(work.state, 'pending'); assert.equal(work.attempts, 1);
+ });
+ await check('002 Executor port: worker cannot withdraw authority, change policy or widen a subscription', async () => {
+ const before = await f.snapshot(`user:${A}`, agent), ticket = Object.keys(before.state.tickets)[0];
+ const worker = f.worker(`user:${A}`, agent, ticket);
+ assert.throws(() => worker.command('configure', {}), /not available/);
+ await worker.command('subscribe', { thread: 'related', kind: 'source' });
+ await worker.command('end', { person: true });
+ const after = await f.snapshot(`user:${A}`, agent);
+ assert.equal(after.state.threads.related.subscriptions[ticket], 'context');
+ assert.equal(after.state.meta[ticket].stopped, undefined);
+ });
+ await check('API: unauthenticated and foreign owner cannot read or mutate fabric', async () => {
+ const anon = await t.get(`/api/v1/fabric/${agent}`); assert([401, 403].includes(anon.status));
+ const foreign = await t.get(`/api/v1/fabric/${agent}`, { as: bob }); assert.equal(foreign.status, 404);
+ const own = await t.get(`/api/v1/fabric/${agent}`, { as: alice }); assert.equal(own.status, 200);
+ const invalid = await t.get(`/api/v1/fabric/${agent}/commands/receive`, { as: alice, headers: { Origin: 'https://openvibe.actor' }, json: {} }); assert.equal(invalid.status, 422);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch(err => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { Engine, initial } = require('../server/fabric/engine');
+const { gate, policy } = require('../server/fabric/policy');
+const { translate, receiveEvent, schedule, tick } = require('../server/fabric/sources');
+const cfg = () => ({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }], sources: { chat: { allowed: ['alice'], bots: ['ci'], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } }, budget: { usd: 1, tokens: 1000, wall_minutes: 10 } } });
+const trigger = (id, extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', ...extra });
+const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested defect fix' }] });
+const engine = () => { const e = new Engine(initial(), { now: 1000 }); e.configure(cfg()); return e; };
+const accept = (e, t = trigger('a')) => { e.receive(t); const ev = e.start({ thread: t.threads[0] }); return e.decide({ evaluation: ev.id, decision: 'accept', scope: t.text }).ticket; };
+let count = 0;
+function test(name, fn) { fn(); count++; console.log(`ok ${name}`); }
+test('001 Trigger: immutable idempotent intake and one acceptance for the whole batch', () => {
+ const e = engine(); e.receive(trigger('a')); e.receive(trigger('b')); e.receive(trigger('a', { text: 'changed' }));
+ assert.equal(e.s.triggers.a.text, 'Fix the broken test');
+ const ev = e.start({ thread: 'room' }); assert.equal(e.start({ thread: 'room' }).id, ev.id);
+ e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Fix test' });
+ e.queue(trigger('b')); assert.equal(e.start({ thread: 'room' }), null); assert.equal(Object.keys(e.s.tickets).length, 1);
+});
+test('001 Thread: new arrivals wait for the next evaluation and see ended Tickets', () => {
+ const e = engine(); e.receive(trigger('a')); const first = e.start({ thread: 'room' }); e.receive(trigger('b'));
+ assert.deepEqual(e.s.threads.room.pending, ['b']);
+ e.decide({ evaluation: first.id, decision: 'ignore' }); assert.deepEqual(e.start({ thread: 'room' }).ids, ['b']);
+});
+test('001 Source: own messages and unlistened scopes are filtered before domain intake', () => {
+ const e = engine(); assert.equal(e.receive(trigger('a', { actor: { source: 'chat', id: 'agent', kind: 'bot' } })).status, 'filtered');
+ assert.equal(e.receive(trigger('b', { source_scope: 'elsewhere' })).status, 'filtered'); assert.deepEqual(e.s.triggers, {});
+});
+test('002 Gate: allowed addressed people explicit in every mode; no assessment work', () => {
+ for (const mode of ['off', 'observe', 'auto']) {
+ const e = engine(), c = cfg(); c.policy.mode = mode; e.configure(c);
+ const k = accept(e); assert.equal(k.mandate.strength, 'explicit'); assert(!e.s.work.some(w => w.kind === 'assess'));
+ }
+});
+test('002 Admission: every cell, follow-up precedence, receive-all and bots', () => {
+ const e = engine(), p = e.s.policy, s = e.s.sources.chat;
+ assert.equal(gate(trigger('a', { addresses_agent: 'none' }), p, s).strength, 'inferred');
+ const other = trigger('a', { actor: { source: 'chat', id: 'bob', kind: 'person' } });
+ assert.equal(gate(other, p, s, true).strength, 'inferred');
+ p.sources.chat.admission.room.unlisted_addressed = 'explicit'; p.mode = 'off';
+ assert.equal(gate(other, p, s, true).strength, 'explicit');
+ assert.equal(gate({ ...other, addresses_agent: 'none' }, p, s, true), null);
+ p.mode = 'auto';
+ assert.equal(gate(trigger('x', { actor: { source: 'chat', id: 'ci', kind: 'bot' } }), p, s).strength, 'inferred');
+ assert.equal(gate(trigger('x', { actor: { source: 'chat', id: 'unknown', kind: 'bot' } }), p, s), null);
+ assert.equal(gate({ ...other, addresses_agent: 'none' }, p, { ...s, receive_all: [] }), null);
+});
+test('002 Assessment: inferred acceptance requires every enabled rule exactly once and quoted direction', () => {
+ for (const mutation of [a => a.rule_assessments.pop(), a => a.rule_assessments.push(a.rule_assessments[0]), a => a.direction.evidence[0].quote = 'invented']) {
+ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' }), a = good(); mutation(a);
+ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', assessment: a })); assert.equal(Object.keys(e.s.tickets).length, 0);
+ }
+ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' });
+ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', scope: 'bypass' }));
+ const k = e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }).ticket;
+ assert.deepEqual(k.mandate.matched_rules, ['fix']); assert.deepEqual(k.mandate.budget, cfg().policy.budget);
+});
+test('002 Assessment: unsettled direction, design decisions and unmatched rules never accept', () => {
+ for (const mutate of [a => a.direction.requires_design_decision = true, a => a.direction.target_basis = 'undetermined', a => a.direction.implementation_requested = false, a => a.rule_assessments[0].matched = false]) {
+ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' }), a = good(); mutate(a);
+ assert.equal(e.decide({ evaluation: ev.id, decision: 'accept', assessment: a }).decision.decision, 'ignore');
+ }
+});
+test('002 Observe default: would-accept is audit only, with no worker or reply', () => {
+ assert.equal(initial().policy.mode, 'observe');
+ const e = engine(), c = cfg(); c.policy.mode = 'observe'; e.configure(c);
+ e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' });
+ const d = e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }).decision;
+ assert.equal(d.decision, 'ignore'); assert.equal(d.would_accept, true); assert(!e.s.work.some(w => ['launch', 'reply'].includes(w.kind)));
+});
+test('002 Routing: ignored-from-card skips assessment and cannot accept', () => {
+ const e = engine(); e.receive(trigger('a', { addresses_agent: 'none' })); const ev = e.start({ thread: 'room' });
+ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', ignored_from_card: true, reason: 'unrelated' }));
+ assert.equal(e.decide({ evaluation: ev.id, decision: 'ignore', ignored_from_card: true, reason: 'unrelated' }).decision.ignored_from_card, true);
+});
+test('001 Policy / 002 Policy version: discard stale Eval and notify affected live Tickets without revoking', () => {
+ const e = engine(), k = accept(e), mandate = structuredClone(k.mandate);
+ e.receive(trigger('b', { actor: { source: 'chat', id: 'ci', kind: 'bot' } })); const ev = e.start({ thread: 'room' });
+ const c = cfg(); c.policy.sources.chat.allowed = []; e.configure(c);
+ assert.equal(k.state, 'open'); assert.deepEqual(k.mandate, mandate); assert(e.s.work.some(w => w.key.startsWith('policy:')));
+ assert.equal(e.s.decisions.at(-1).decision, 'unavailable'); assert.deepEqual(e.s.threads.room.pending, ['b']);
+ assert.throws(() => e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }));
+});
+test('001 Subscription / 002 Ownership: every sender fans out, human follow-ups bypass Eval', () => {
+ const e = engine(), k = accept(e);
+ e.receive(trigger('b', { actor: { source: 'chat', id: 'stranger', kind: 'person' } }));
+ assert.deepEqual(e.owners('b'), [k.id]); assert.equal(e.start({ thread: 'room' }), null);
+ e.unsubscribe({ ticket_id: k.id, thread: 'room', person: true }); assert.deepEqual(e.owners('b'), [k.id]);
+ e.subscribe({ ticket_id: k.id, thread: 'room', kind: 'source' });
+ e.receive(trigger('c', { actor: { source: 'chat', id: 'unknown', kind: 'bot' } }));
+ assert(e.s.work.some(w => w.payload.trigger?.id === 'c')); assert.deepEqual(e.owners('c'), []);
+});
+test('001 Correlation / 002 Context-only: related Threads never route or grant resume authority', () => {
+ const e = engine(), k = accept(e);
+ e.subscribe({ ticket_id: k.id, thread: 'context' }); e.end({ ticket_id: k.id });
+ e.receive(trigger('b', { threads: ['context'] })); assert.equal(k.state, 'ended'); assert.deepEqual(e.owners('b'), []);
+ e.s.threads.context.correlated = ['room']; e.receive(trigger('c', { threads: ['context'] })); assert.equal(k.state, 'ended');
+});
+test('002 Hand-back: once with a reason; wait until every owner releases; keep subscriptions', () => {
+ const e = engine(), one = accept(e);
+ e.receive(trigger('bot', { actor: { source: 'chat', id: 'ci', kind: 'bot' } }));
+ const ev = e.start({ thread: 'room' }), a = good(); a.direction.evidence[0].trigger_id = 'bot';
+ const two = e.decide({ evaluation: ev.id, decision: 'accept', assessment: a }).ticket;
+ e.receive(trigger('follow'));
+ assert.throws(() => e.handBack({ ticket_id: one.id, trigger_id: 'follow', reason: '' }));
+ assert(e.handBack({ ticket_id: one.id, trigger_id: 'follow', reason: 'Another scope' })); assert.equal(e.s.threads.room.pending.length, 0);
+ assert.equal(e.handBack({ ticket_id: one.id, trigger_id: 'follow', reason: 'again' }), false);
+ e.handBack({ ticket_id: two.id, trigger_id: 'follow', reason: 'Another scope' }); assert.deepEqual(e.s.threads.room.pending, ['follow']);
+ assert(e.s.threads.room.subscriptions[one.id]);
+});
+test('002 Resume: person resumes same immutable Mandate, bots cannot; revoked policy fails closed', () => {
+ const e = engine(), k = accept(e), mandate = structuredClone(k.mandate);
+ e.end({ ticket_id: k.id });
+ e.receive(trigger('bot', { actor: { source: 'chat', id: 'ci', kind: 'bot' } })); assert.equal(k.state, 'ended');
+ e.receive(trigger('person')); assert.equal(k.state, 'open'); assert.deepEqual(k.mandate, mandate);
+ e.read({ ticket_id: k.id, trigger_ids: ['person'] }); e.end({ ticket_id: k.id });
+ const c = cfg(); c.policy.sources.chat.allowed = []; c.policy.sources.chat.admission.room.unlisted_addressed = 'ignore'; e.configure(c);
+ e.receive(trigger('again')); assert.equal(k.state, 'ended'); assert.deepEqual(e.owners('again'), []); assert(e.s.threads.room.pending.includes('again'));
+});
+test('002 Stop: only explicit human stop withdraws; stopped Ticket receives nothing', () => {
+ const e = engine(), k = accept(e);
+ e.receive(trigger('bot', { actor: { source: 'chat', id: 'ci', kind: 'bot' } })); assert.throws(() => e.stop({ ticket_id: k.id, trigger_id: 'bot' }));
+ e.receive(trigger('stop')); e.stop({ ticket_id: k.id, trigger_id: 'stop', reason: 'Person asked' });
+ e.receive(trigger('later')); assert.equal(k.state, 'ended'); assert(!e.s.work.some(w => w.payload.trigger?.id === 'later'));
+});
+test('002 Release unread: read input stays accounted for; unread returns to routing on end', () => {
+ const e = engine(), k = accept(e); e.receive(trigger('read')); e.receive(trigger('unread'));
+ e.read({ ticket_id: k.id, trigger_ids: ['read'] }); e.end({ ticket_id: k.id }); assert.deepEqual(e.s.threads.room.pending, ['unread']);
+});
+test('002 Eval timeout: undelivered never expires; third delivered expiry is unavailable', () => {
+ const e = engine(); e.receive(trigger('a')); let ev = e.start({ thread: 'room' }); e.now = 9999999; e.expire(); assert(e.s.threads.room.evaluation);
+ for (let i = 0; i < 3; i++) { e.delivered({ evaluation: ev.id }); e.now += 300001; e.expire(); if (i < 2) ev = e.start({ thread: 'room' }); }
+ assert.equal(e.s.threads.room.evaluation, null); assert.equal(e.s.threads.room.pending.length, 0); assert.equal(e.s.decisions.at(-1).decision, 'unavailable');
+});
+test('003 Stored work: order, exponential retries, leases and restart use the same idempotency key', () => {
+ const e = engine(), k = accept(e); e.reply({ ticket_id: k.id, conversation: 'room', source: 'chat', key: 'ack', text: 'Acknowledged' }); e.reply({ ticket_id: k.id, conversation: 'room', source: 'chat', key: 'answer', text: 'Result' });
+ let w = e.claim({ kinds: ['reply'] }); e.settle({ id: w.id, claim: w.claim, ok: false });
+ assert.equal(e.s.work.find(x => x.id === w.id).due, 6000); assert.equal(e.claim({ kinds: ['reply'] }), null);
+ e.now = 6000; w = e.claim({ kinds: ['reply'] });
+ const restarted = new Engine(structuredClone(e.s), { now: 37000 }); const retry = restarted.claim({ kinds: ['reply'] }); assert.equal(retry.key, w.key); assert.notEqual(retry.claim, w.claim);
+ assert.throws(() => restarted.settle({ id: w.id, claim: w.claim, ok: true })); restarted.settle({ id: retry.id, claim: retry.claim, ok: true });
+ assert.equal(restarted.claim({ kinds: ['reply'] }).payload.text, 'Result');
+});
+test('003 Channel: Source off waits; permanent drop records when and why and never requeues', () => {
+ const e = engine(), k = accept(e), args = { ticket_id: k.id, conversation: 'room', source: 'chat', key: 'ack', text: 'Ack' };
+ e.reply(args); e.s.sources.chat.enabled = false; assert.equal(e.claim({ kinds: ['reply'] }), null); e.s.sources.chat.enabled = true;
+ const w = e.claim({ kinds: ['reply'] }); e.settle({ id: w.id, claim: w.claim, permanent_reason: 'destination_cannot_receive' });
+ assert.equal(e.reply(args).state, 'dropped'); assert.equal(e.reply(args).finished_at, e.now);
+});
+test('001 Source plugins: Chat mention/DM and Mail bind scope and owner; cursor shares intake', () => {
+ const e = engine(); e.s.sources.chat.scopes = ['global', 'dm:2']; e.s.sources.chat.mentions = ['actor']; e.s.sources.chat.owner_subject = 'alice';
+ const event = { id: 'ev1', event_type: 'chat.message.created', payload: { message_id: 1, room: { type: 'global' }, user_subject: 'alice', text: '@actor fix test' } };
+ assert.equal(receiveEvent(e, event)[0].status, 'stored'); assert.equal(e.s.sources.chat.cursor, 'ev1'); assert.equal(receiveEvent(e, event)[0].status, 'duplicate');
+ const dm = { event_type: 'chat.dm.created', payload: { message_id: 2, conversation_id: 2, sender_subject: 'alice', participants: [{ subject: 'alice' }, { subject: 'agent' }] } };
+ assert.equal(translate(dm, 'chat', e.s.sources.chat, e.now).addresses_agent, 'direct'); dm.payload.participants = [{ subject: 'agent' }]; assert.equal(translate(dm, 'chat', e.s.sources.chat, e.now), null);
+ const mail = { kind: 'mail', enabled: true, self: '[email protected]', owner_subject: 'alice', mail_agent: 'agent', scopes: ['inbox:1'] };
+ const letter = { event_type: 'mail.agent_inbox.received', payload: { inbox_id: 1, owner: 'alice', agent_id: 'agent', from: '[email protected]', message_id: 'm1', inbox: '[email protected]', excerpt: 'hello' } };
+ const full = { from: ['[email protected]'], owner: 'alice', agent_id: 'agent', inbox: '[email protected]', text: 'hello', message_id: 'm1', references: [] };
+ const first = translate(letter, 'mail', mail, e.now, full); assert.equal(first.actor.id, '[email protected]');
+ const next = translate({ ...letter, payload: { ...letter.payload, message_id: 'm2' } }, 'mail', mail, e.now, { ...full, message_id: 'm2', references: ['m1'] });
+ assert.deepEqual(first.threads, next.threads);
+ const unrelated = translate({ ...letter, payload: { ...letter.payload, message_id: 'm3' } }, 'mail', mail, e.now, { ...full, message_id: 'm3' });
+ assert.notDeepEqual(first.threads, unrelated.threads);
+ letter.payload.owner = 'bob'; assert.equal(translate(letter, 'mail', mail, e.now, full), null);
+});
+test('001 / 002 Waiting conditions: time Source produces idempotent events; evaluator never waits', () => {
+ const e = engine(); e.s.sources.clock = { kind: 'time', enabled: true, self: 'agent', scopes: ['morning'], receive_all: ['morning'] };
+ schedule(e, { id: 'daily', source: 'clock', scope: 'morning', due: 2000, text: 'Morning check' }); tick(e); assert.equal(Object.keys(e.s.triggers).length, 0);
+ e.now = 2000; tick(e); tick(e); assert.equal(Object.keys(e.s.triggers).length, 1); assert.equal(e.s.triggers[Object.keys(e.s.triggers)[0]].actor.kind, 'system');
+});
+test('002 Revalidation: table weakening, rule disablement, bot removal and Source removal revoke on resume', () => {
+ for (const change of [c => c.policy.mode = 'observe', c => c.policy.rules[0].enabled = false, c => c.policy.sources.chat.bots = [], c => c.sources.chat.enabled = false]) {
+ const e = engine(); e.receive(trigger('a', { actor: { source: 'chat', id: 'ci', kind: 'bot' } }));
+ const ev = e.start({ thread: 'room' }), k = e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }).ticket;
+ e.end({ ticket_id: k.id }); const c = cfg(); change(c); e.configure(c); e.receive(trigger('later')); assert.equal(k.state, 'ended');
+ }
+ const e = engine(), c = cfg(); c.policy.sources.chat.admission.room.unlisted_addressed = 'explicit'; e.configure(c);
+ const k = accept(e, trigger('a', { actor: { source: 'chat', id: 'bob', kind: 'person' } })); e.end({ ticket_id: k.id });
+ c.policy.sources.chat.admission.room.unlisted_addressed = 'inferred'; e.configure(c); e.receive(trigger('later')); assert.equal(k.state, 'ended');
+});
+test('002 Attach: explicit pointer only, context upgraded, handed-back work cannot return', () => {
+ const e = engine(), k = accept(e); e.subscribe({ ticket_id: k.id, thread: 'other' });
+ e.receive(trigger('other1', { threads: ['other'], text: 'Different work' }));
+ assert.throws(() => e.attach({ ticket_id: k.id, trigger_id: 'other1' }));
+ e.receive(trigger('other2', { threads: ['other'], text: `Please continue ${k.id}` }));
+ e.attach({ ticket_id: k.id, trigger_id: 'other2' }); assert.deepEqual(e.owners('other2'), [k.id]);
+ e.handBack({ ticket_id: k.id, trigger_id: 'other2', reason: 'Beyond scope' }); assert.throws(() => e.attach({ ticket_id: k.id, trigger_id: 'other2' }));
+ assert.throws(() => e.unsubscribe({ ticket_id: k.id, thread: 'room' }));
+});
+test('001 Subscription: many Threads deliver one input, multiple Tickets remain independent', () => {
+ const e = engine(), k = accept(e); e.subscribe({ ticket_id: k.id, thread: 'other', kind: 'source' });
+ e.receive(trigger('follow', { threads: ['room', 'other'] })); assert.equal(e.s.work.filter(w => w.payload.trigger?.id === 'follow').length, 1);
+});
+test('001 Policy: unchanged settings preserve version and unrelated changes do not notify', () => {
+ const e = engine(), k = accept(e), old = e.s.policy.version; e.configure(cfg()); assert.equal(e.s.policy.version, old);
+ const c = cfg(); c.policy.rules.push({ id: 'unrelated', enabled: true, instruction: 'Other work' }); e.configure(c);
+ assert.notEqual(e.s.policy.version, old); assert(!e.s.work.some(w => w.key.startsWith('policy:'))); assert.equal(k.state, 'open');
+});
+test('001 Time Source: a later message cancels a quiet condition before it can produce work', () => {
+ const e = engine(); e.s.sources.clock = { kind: 'time', enabled: true, self: 'agent', scopes: ['quiet'], receive_all: ['quiet'] };
+ e.receive(trigger('a'));
+ schedule(e, { id: 'quiet', source: 'clock', scope: 'quiet', due: 2000, text: 'No reply', quiet_thread: 'room', since_trigger: 'a' });
+ e.receive(trigger('b')); e.now = 2000; tick(e);
+ assert.equal(e.s.schedules.quiet.reason, 'conversation_changed'); assert.equal(Object.values(e.s.triggers).filter(t => t.source === 'clock').length, 0);
+});
+test('003 Channel: removing a destination drops a stored reply with a durable reason', () => {
+ const e = engine(), k = accept(e);
+ const reply = e.reply({ ticket_id: k.id, conversation: 'room', source: 'chat', key: 'ack', text: 'Acknowledged' });
+ e.s.sources.chat.scopes = []; assert.equal(e.claim({ kinds: ['reply'] }), null);
+ assert.equal(reply.state, 'dropped'); assert.equal(reply.reason, 'source_no_longer_listens');
+});
+test('003 Stored work: failed input delivery is visible and successful retry clears it', () => {
+ const e = engine(), k = accept(e); let w = e.claim({ kinds: ['launch'] }); e.settle({ id: w.id, claim: w.claim, ok: true });
+ e.receive(trigger('follow')); w = e.claim({ kinds: ['input'] }); e.settle({ id: w.id, claim: w.claim, ok: false });
+ assert.equal(k.stage, 'person/delivery'); assert.equal(e.s.work.find(x => x.id === w.id).due, 3000);
+ e.now = 3000; w = e.claim({ kinds: ['input'] }); e.settle({ id: w.id, claim: w.claim, ok: true }); assert.equal(k.stage, 'working');
+});
+test('001 Thread: out-of-order Source replay is presented in event-time order', () => {
+ const e = engine(); e.receive(trigger('new', { occurred_at: '2026-11-09T13:00:00.000Z' })); e.receive(trigger('old'));
+ assert.deepEqual(e.s.threads.room.triggers, ['old', 'new']); assert.deepEqual(e.start({ thread: 'room' }).ids, ['old', 'new']);
+});
+test('002 Revalidation: an explicit follow-up mandate survives allowed-list changes', () => {
+ const e = engine(), first = accept(e); e.receive(trigger('follow', { addresses_agent: 'none' }));
+ e.handBack({ ticket_id: first.id, trigger_id: 'follow', reason: 'Independent scope' });
+ const ev = e.start({ thread: 'room' }), second = e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Follow-up' }).ticket;
+ assert.equal(e.s.meta[second.id].basis, 'followup'); e.end({ ticket_id: second.id });
+ const c = cfg(); c.policy.sources.chat.allowed = []; e.configure(c); e.receive(trigger('resume')); assert.equal(second.state, 'open');
+});
+test('Command boundary: inherited object names cannot identify Tickets or Threads', () => {
+ const e = engine();
+ for (const id of ['__proto__', 'constructor', 'prototype']) {
+ assert.throws(() => e.end({ ticket_id: id })); assert.throws(() => e.start({ thread: id }));
+ }
+ assert.equal(Object.prototype.state, undefined);
+});
+console.log(`${count} fabric rule tests passed`);