openvibers/openvibe.events Commits
Events: the network agent namespace (agent.*) (#246)
Changes
@@@ -79,7 +79,7 @@ project's hourly publishing and delivery rollup, for Network; never visible to a`POST /api/v1/events` takes one `events.event-envelope@1` envelope, or `{ "events": [ … ] }` (up to 100, stored atomically).--- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`, `bot.*`, `watch.*`, `run.*`, `space.*`, and the publication products (`wiki.*`, `blog.*`, `news.*`, `reviews.*`, `deals.*`, `coupons.*`, `trade.*`, `sources.*`, `search.*`); `EVENTS_SOURCE_PREFIXES` overrides). A source's namespace is its id without hyphens, since an event type has none: `svc:media-hub` publishes `source: "media-hub"` with `mediahub.*` types, and no other source can claim `mediahub.*`. A network-wide prefix may be published by any first-party service: `provider.*` carries storage provider health/capacity telemetry (`provider.health.degraded`, `provider.capacity.warning`), so whichever service observes a provider can report it; `EVENTS_SHARED_PREFIXES` overrides.++- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`, `bot.*`, `watch.*`, `run.*`, `space.*`, and the publication products (`wiki.*`, `blog.*`, `news.*`, `reviews.*`, `deals.*`, `coupons.*`, `trade.*`, `sources.*`, `search.*`); `EVENTS_SOURCE_PREFIXES` overrides). A source's namespace is its id without hyphens, since an event type has none: `svc:media-hub` publishes `source: "media-hub"` with `mediahub.*` types, and no other source can claim `mediahub.*`. A network-wide prefix may be published by any first-party service: `provider.*` carries storage provider health/capacity telemetry (`provider.health.degraded`, `provider.capacity.warning`), so whichever service observes a provider can report it; `agent.*` is the network's agent namespace (design 2026-11-07): every mode of the one agent publishes the steps it takes into it (`agent.step.started|updated|ended`), so Chat, Codes, Run, Watch, Live and Surf can each publish the step they just ran; `EVENTS_SHARED_PREFIXES` overrides.- Idempotent on `event_id`: a repeat answers `200 { event_id, cursor, duplicate: true }` (`pruned: true` and no cursor when retention already removed the stored copy) and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`. The chain depth of a first-party publish counts first-party events only (developer-app events in the same trace never count toward it), so an app cannot poison a trace it has seen.@@@ -65,11 +65,15 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr* provider health/capacity telemetry, which whichever first-party service observes a provider* publishes (Media, Sources, Host, ...), so any service token may publish into it. `share.*` is* the sharing family Network publishes (share.granted, share.revoked, share.transfer.*), named by-- * its family rather than by the network namespace in Contracts' network manifest. Sources still-- * own their own namespace first; a shared prefix never replaces `<source>.`. Developer apps can-- * never publish a shared prefix. EVENTS_SHARED_PREFIXES (comma-separated) replaces the default.++ * its family rather than by the network namespace in Contracts' network manifest. `agent.*` is the++ * network's agent namespace (design 2026-11-07): every mode of the one agent publishes the steps it++ * takes into it (agent.step.started|updated|ended), and OpenVibe.Actor keeps the one timeline behind++ * the Dock. Sources still own their own namespace first; a shared prefix never replaces `<source>.`.++ * Developer apps can never publish a shared prefix. EVENTS_SHARED_PREFIXES (comma-separated)++ * replaces the default.*/--const DEFAULT_SHARED_PREFIXES = ['provider.', 'share.'];++const DEFAULT_SHARED_PREFIXES = ['provider.', 'share.', 'agent.'];++function sourcePrefixes(env) {let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${namespaceOf(s)}.`]]));@@@ -10,6 +10,26 @@ const live = serviceToken('live', ['events.event.publish']);t('boot', async () => { h = await boot(); });++t('the network agent namespace: any first-party service may publish agent.step.*', async () => {++ const codes = serviceToken('codes', ['events.event.publish']);++ const r = await request(h.base, 'POST', '/api/v1/events', {++ token: codes,++ body: envelope('codes', { event_type: 'agent.step.started', subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }, payload: { step_id: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPS' } }),++ });++ assert.strictEqual(r.status, 201, r.text);++ // A developer app never publishes into a network-wide namespace.++ const appId = ids.newId('app');++ const appUlid = appId.split('_')[1].toLowerCase();++ const app = await request(h.base, 'POST', '/api/v1/events', {++ token: appToken({ appId, cap: ['events.app.publish'] }),++ body: envelope('app', { source: `app-${appUlid}`, actor: { type: 'app', id: appId }, event_type: 'agent.step.started' }),++ });++ assert.strictEqual(app.status, 403, JSON.stringify(app.body));++ assert.strictEqual(app.body.code, 'events.type_not_allowed', JSON.stringify(app.body));++ });++++++t('no token -> 401, wrong audience -> 401, missing capability -> 403', async () => {let r = await request(h.base, 'POST', '/api/v1/events', { body: envelope() });assert.strictEqual(r.status, 401);@@@ -43,7 +43,8 @@ t('config: every source prefix must start with the source', () => {});t('config: network-wide prefixes are shared, source namespaces stay owned', () => {-- assert.deepStrictEqual(load({}).sharedPrefixes, ['provider.', 'share.']);++ assert.deepStrictEqual(load({}).sharedPrefixes, ['provider.', 'share.', 'agent.']);++const c = load({ EVENTS_SHARED_PREFIXES: 'provider.,storage.' });assert.deepStrictEqual(c.sharedPrefixes, ['provider.', 'storage.']);assert.throws(() => load({ EVENTS_SHARED_PREFIXES: 'provider' }), /bad prefix/);
@@ -79,7 +79,7 @@ project's hourly publishing and delivery rollup, for Network; never visible to a
@@ -79,7 +79,7 @@ project's hourly publishing and delivery rollup, for Network; never visible to a
`POST /api/v1/events` takes one `events.event-envelope@1` envelope, or `{ "events": [ … ] }` (up to 100, stored atomically).
`POST /api/v1/events` takes one `events.event-envelope@1` envelope, or `{ "events": [ … ] }` (up to 100, stored atomically).
-- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`, `bot.*`, `watch.*`, `run.*`, `space.*`, and the publication products (`wiki.*`, `blog.*`, `news.*`, `reviews.*`, `deals.*`, `coupons.*`, `trade.*`, `sources.*`, `search.*`); `EVENTS_SOURCE_PREFIXES` overrides). A source's namespace is its id without hyphens, since an event type has none: `svc:media-hub` publishes `source: "media-hub"` with `mediahub.*` types, and no other source can claim `mediahub.*`. A network-wide prefix may be published by any first-party service: `provider.*` carries storage provider health/capacity telemetry (`provider.health.degraded`, `provider.capacity.warning`), so whichever service observes a provider can report it; `EVENTS_SHARED_PREFIXES` overrides.
+- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`, `bot.*`, `watch.*`, `run.*`, `space.*`, and the publication products (`wiki.*`, `blog.*`, `news.*`, `reviews.*`, `deals.*`, `coupons.*`, `trade.*`, `sources.*`, `search.*`); `EVENTS_SOURCE_PREFIXES` overrides). A source's namespace is its id without hyphens, since an event type has none: `svc:media-hub` publishes `source: "media-hub"` with `mediahub.*` types, and no other source can claim `mediahub.*`. A network-wide prefix may be published by any first-party service: `provider.*` carries storage provider health/capacity telemetry (`provider.health.degraded`, `provider.capacity.warning`), so whichever service observes a provider can report it; `agent.*` is the network's agent namespace (design 2026-11-07): every mode of the one agent publishes the steps it takes into it (`agent.step.started|updated|ended`), so Chat, Codes, Run, Watch, Live and Surf can each publish the step they just ran; `EVENTS_SHARED_PREFIXES` overrides.
- Idempotent on `event_id`: a repeat answers `200 { event_id, cursor, duplicate: true }` (`pruned: true` and no cursor when retention already removed the stored copy) and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).
- Idempotent on `event_id`: a repeat answers `200 { event_id, cursor, duplicate: true }` (`pruned: true` and no cursor when retention already removed the stored copy) and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).
- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.
- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.
- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`. The chain depth of a first-party publish counts first-party events only (developer-app events in the same trace never count toward it), so an app cannot poison a trace it has seen.
- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`. The chain depth of a first-party publish counts first-party events only (developer-app events in the same trace never count toward it), so an app cannot poison a trace it has seen.
@@ -65,11 +65,15 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
@@ -65,11 +65,15 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
* provider health/capacity telemetry, which whichever first-party service observes a provider
* provider health/capacity telemetry, which whichever first-party service observes a provider
* publishes (Media, Sources, Host, ...), so any service token may publish into it. `share.*` is
* publishes (Media, Sources, Host, ...), so any service token may publish into it. `share.*` is
* the sharing family Network publishes (share.granted, share.revoked, share.transfer.*), named by
* the sharing family Network publishes (share.granted, share.revoked, share.transfer.*), named by
- * its family rather than by the network namespace in Contracts' network manifest. Sources still
- * own their own namespace first; a shared prefix never replaces `<source>.`. Developer apps can
- * never publish a shared prefix. EVENTS_SHARED_PREFIXES (comma-separated) replaces the default.
+ * its family rather than by the network namespace in Contracts' network manifest. `agent.*` is the
+ * network's agent namespace (design 2026-11-07): every mode of the one agent publishes the steps it
+ * takes into it (agent.step.started|updated|ended), and OpenVibe.Actor keeps the one timeline behind
+ * the Dock. Sources still own their own namespace first; a shared prefix never replaces `<source>.`.
+ * Developer apps can never publish a shared prefix. EVENTS_SHARED_PREFIXES (comma-separated)
+ * replaces the default.
*/
*/
-const DEFAULT_SHARED_PREFIXES = ['provider.', 'share.'];
+const DEFAULT_SHARED_PREFIXES = ['provider.', 'share.', 'agent.'];
+
function sourcePrefixes(env) {
function sourcePrefixes(env) {
let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${namespaceOf(s)}.`]]));
let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${namespaceOf(s)}.`]]));
@@ -10,6 +10,26 @@ const live = serviceToken('live', ['events.event.publish']);
@@ -10,6 +10,26 @@ const live = serviceToken('live', ['events.event.publish']);
t('boot', async () => { h = await boot(); });
t('boot', async () => { h = await boot(); });
+t('the network agent namespace: any first-party service may publish agent.step.*', async () => {
+ const codes = serviceToken('codes', ['events.event.publish']);
+ const r = await request(h.base, 'POST', '/api/v1/events', {
+ token: codes,
+ body: envelope('codes', { event_type: 'agent.step.started', subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }, payload: { step_id: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPS' } }),
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ // A developer app never publishes into a network-wide namespace.
+ const appId = ids.newId('app');
+ const appUlid = appId.split('_')[1].toLowerCase();
+ const app = await request(h.base, 'POST', '/api/v1/events', {
+ token: appToken({ appId, cap: ['events.app.publish'] }),
+ body: envelope('app', { source: `app-${appUlid}`, actor: { type: 'app', id: appId }, event_type: 'agent.step.started' }),
+ });
+ assert.strictEqual(app.status, 403, JSON.stringify(app.body));
+ assert.strictEqual(app.body.code, 'events.type_not_allowed', JSON.stringify(app.body));
+ });
+
+
+
t('no token -> 401, wrong audience -> 401, missing capability -> 403', async () => {
t('no token -> 401, wrong audience -> 401, missing capability -> 403', async () => {
let r = await request(h.base, 'POST', '/api/v1/events', { body: envelope() });
let r = await request(h.base, 'POST', '/api/v1/events', { body: envelope() });
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
@@ -43,7 +43,8 @@ t('config: every source prefix must start with the source', () => {
@@ -43,7 +43,8 @@ t('config: every source prefix must start with the source', () => {
});
});
t('config: network-wide prefixes are shared, source namespaces stay owned', () => {
t('config: network-wide prefixes are shared, source namespaces stay owned', () => {
- assert.deepStrictEqual(load({}).sharedPrefixes, ['provider.', 'share.']);
+ assert.deepStrictEqual(load({}).sharedPrefixes, ['provider.', 'share.', 'agent.']);
+
const c = load({ EVENTS_SHARED_PREFIXES: 'provider.,storage.' });
const c = load({ EVENTS_SHARED_PREFIXES: 'provider.,storage.' });
assert.deepStrictEqual(c.sharedPrefixes, ['provider.', 'storage.']);
assert.deepStrictEqual(c.sharedPrefixes, ['provider.', 'storage.']);
assert.throws(() => load({ EVENTS_SHARED_PREFIXES: 'provider' }), /bad prefix/);
assert.throws(() => load({ EVENTS_SHARED_PREFIXES: 'provider' }), /bad prefix/);